.prettierrc.json was missing "semi": false, so prettier wanted to add semicolons to a codebase written without them (763 semicolon-free statements vs. 150 with, in hand-written .ts; zero hand-written .svelte files use them at all). That's why prettier --check failed on 249 files — not because the code was unformatted, but because the config didn't match the actual house style. Added "semi": false; left printWidth/etc as configured (printWidth barely moves the failure count: 218/213/212 files at 100/120/140). Ran `prettier --write .` with the corrected config. Verified semantics-preserving before and after: - eslint: 142 problems both before and after, byte-identical - build passes, 38/38 tests pass - token-stream diff (whitespace/semicolons/quotes normalized) on all 218 changed files: only 52 had any remaining token change, all either trailing-comma removal (matching trailingComma: "none") or import/ ternary reflow — no semantic changes - live smoke test: Knowledge, Tasks, Fleet map, and a chat window (AgentTrace, markdown, Scope graph, activity rail) all render correctly, no console errors Most of the diff is shadcn/ui vendor files (lib/components/ui/) moving from the CLI's own style (double quotes, tabs, semicolons) to house style; re-running `shadcn-svelte add` on a component will need a follow-up format pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
447 lines
16 KiB
Svelte
447 lines
16 KiB
Svelte
<script lang="ts">
|
|
// The desktop's Settings window — a generic shell (section list + content
|
|
// pane) so future settings just add a SECTIONS entry instead of a whole
|
|
// new window/screen. Connection reuses the same config.ts/oidc.ts calls as
|
|
// pages/Config.svelte (the full-page initial setup screen), just without
|
|
// that screen's "first run" framing — this is for changing settings while
|
|
// already inside the desktop.
|
|
import { Input } from '$lib/components/ui/input'
|
|
import { Label } from '$lib/components/ui/label'
|
|
import { Button } from '$lib/components/ui/button'
|
|
import { Separator } from '$lib/components/ui/separator'
|
|
import { Checkbox } from '$lib/components/ui/checkbox'
|
|
import { Slider } from '$lib/components/ui/slider'
|
|
import {
|
|
fetchWithAuth,
|
|
setConfig,
|
|
initConfig,
|
|
getConfig,
|
|
clearConfig,
|
|
type OikosConfig
|
|
} from '$lib/config'
|
|
import { startLogin, logout as oidcLogout, getUser, isOIDCConfigured } from '$lib/oidc'
|
|
import { getTheme, setTheme, THEME_LABELS, type Theme } from '$lib/stores/theme.svelte'
|
|
import {
|
|
getBackground,
|
|
setBackgroundPattern,
|
|
setPatternColor,
|
|
setFillColor,
|
|
setBackgroundOpacity,
|
|
setBackgroundFade,
|
|
setBackgroundScale,
|
|
setBackgroundRotation
|
|
} from '$lib/stores/background.svelte'
|
|
import { PATTERNS, patternCss, type BackgroundPatternId } from '$lib/desktop-patterns'
|
|
import { VERSION } from '$lib/version'
|
|
import { toast } from 'svelte-sonner'
|
|
import PlugIcon from '@lucide/svelte/icons/plug'
|
|
import PaletteIcon from '@lucide/svelte/icons/palette'
|
|
import LockIcon from '@lucide/svelte/icons/lock'
|
|
import LogInIcon from '@lucide/svelte/icons/log-in'
|
|
import ServerIcon from '@lucide/svelte/icons/server'
|
|
import CheckIcon from '@lucide/svelte/icons/check'
|
|
|
|
const SECTIONS = [
|
|
{ id: 'appearance', label: 'Appearance', icon: PaletteIcon },
|
|
{ id: 'connection', label: 'Connection', icon: PlugIcon }
|
|
] as const
|
|
type SectionId = (typeof SECTIONS)[number]['id']
|
|
|
|
let section = $state<SectionId>('appearance')
|
|
|
|
const existing = getConfig()
|
|
let apiUrl = $state(existing.apiUrl ?? '')
|
|
let token = $state(existing.token ?? '')
|
|
let saving = $state(false)
|
|
let error = $state('')
|
|
let oidcLoggingIn = $state(false)
|
|
const oidcUser = getUser()
|
|
const oidcConfigured = isOIDCConfigured()
|
|
|
|
async function save() {
|
|
error = ''
|
|
if (!token.trim()) {
|
|
error = 'Token is required'
|
|
return
|
|
}
|
|
saving = true
|
|
const prev: OikosConfig = getConfig()
|
|
setConfig({ apiUrl: apiUrl.trim(), token: token.trim() })
|
|
initConfig({ apiUrl: apiUrl.trim(), token: token.trim() })
|
|
try {
|
|
const res = await fetchWithAuth('/api/v1/dashboard/summary')
|
|
if (!res.ok) {
|
|
error = res.status === 401 ? 'Invalid token' : `Server responded ${res.status}`
|
|
setConfig(prev)
|
|
initConfig(prev)
|
|
return
|
|
}
|
|
toast.success('Connection saved')
|
|
} catch {
|
|
error = 'Could not reach server — check the URL'
|
|
setConfig(prev)
|
|
initConfig(prev)
|
|
} finally {
|
|
saving = false
|
|
}
|
|
}
|
|
|
|
async function loginWithAuthentik() {
|
|
error = ''
|
|
oidcLoggingIn = true
|
|
setConfig({ apiUrl: apiUrl.trim(), token: '' })
|
|
initConfig({ apiUrl: apiUrl.trim(), token: '' })
|
|
try {
|
|
await startLogin()
|
|
} catch (e: unknown) {
|
|
error = e instanceof Error ? e.message : 'OIDC login failed'
|
|
oidcLoggingIn = false
|
|
}
|
|
}
|
|
|
|
// Disconnecting invalidates the token every open window's data depends on
|
|
// — a reload is the simplest way back to a clean state (lands on the
|
|
// full-page Config screen since isConfigured() is now false).
|
|
function forgetConnection() {
|
|
clearConfig()
|
|
oidcLogout()
|
|
location.reload()
|
|
}
|
|
|
|
function pickTheme(t: Theme) {
|
|
setTheme(t)
|
|
}
|
|
|
|
// Swatch previews render every pattern at a fixed, higher-contrast opacity
|
|
// against a neutral tile so the shape reads clearly in the picker — the
|
|
// live opacity slider (often tuned low, e.g. 0.08, so it reads as texture
|
|
// rather than noise) would make most patterns nearly invisible here.
|
|
const SWATCH_PREVIEW_OPACITY = 0.55
|
|
|
|
function swatchStyle(id: BackgroundPatternId, color: string): string {
|
|
const css = patternCss(id, color)
|
|
return css ? `opacity:${SWATCH_PREVIEW_OPACITY};${css}` : ''
|
|
}
|
|
|
|
// Remembers the last custom fill color locally so toggling "Custom" off
|
|
// and back on doesn't lose the pick — the store itself only ever holds
|
|
// null (auto/theme) or the active custom color, not a disabled draft.
|
|
let fillDraft = $state(getBackground().fillColor ?? '#ffffff')
|
|
|
|
function onFillToggle(checked: boolean) {
|
|
setFillColor(checked ? fillDraft : null)
|
|
}
|
|
|
|
function onFillColorInput(e: Event) {
|
|
const val = (e.currentTarget as HTMLInputElement).value
|
|
fillDraft = val
|
|
setFillColor(val)
|
|
}
|
|
|
|
// Size/rotation act on the pattern image itself — meaningless with no
|
|
// pattern selected, unlike opacity/fade which also apply to a plain fill
|
|
// color wash (pattern 'none' + a custom fill).
|
|
const noPattern = $derived(getBackground().pattern === 'none')
|
|
</script>
|
|
|
|
<div class="flex h-full min-h-0 flex-col">
|
|
<div class="flex min-h-0 flex-1">
|
|
<nav class="flex w-44 shrink-0 flex-col gap-0.5 border-r bg-muted/20 p-2">
|
|
{#each SECTIONS as s (s.id)}
|
|
<button
|
|
type="button"
|
|
class="flex items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors {section ===
|
|
s.id
|
|
? 'bg-muted font-medium text-foreground'
|
|
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'}"
|
|
onclick={() => (section = s.id)}
|
|
>
|
|
<s.icon class="size-4 shrink-0" />
|
|
{s.label}
|
|
</button>
|
|
{/each}
|
|
</nav>
|
|
|
|
<div class="min-h-0 flex-1 overflow-y-auto p-6">
|
|
{#if section === 'connection'}
|
|
<div class="mx-auto flex max-w-md flex-col gap-5">
|
|
<div>
|
|
<h2 class="text-sm font-semibold">Connection</h2>
|
|
<p class="mt-0.5 text-xs text-muted-foreground">
|
|
Where the control room talks to your Oikos server.
|
|
</p>
|
|
</div>
|
|
|
|
{#if oidcUser}
|
|
<p class="text-xs text-muted-foreground">
|
|
Signed in via Authentik as <span class="font-medium text-foreground">{oidcUser}</span>
|
|
</p>
|
|
{/if}
|
|
|
|
<div class="flex flex-col gap-1.5">
|
|
<Label for="settings-server-url" class="text-xs font-medium">Server URL</Label>
|
|
<div class="relative">
|
|
<ServerIcon
|
|
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground/50"
|
|
/>
|
|
<Input
|
|
id="settings-server-url"
|
|
type="url"
|
|
placeholder="https://oikos.hubris.network"
|
|
class="pl-9"
|
|
bind:value={apiUrl}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
disabled={oidcLoggingIn}
|
|
onclick={loginWithAuthentik}
|
|
class="w-full gap-2"
|
|
>
|
|
<LogInIcon class="size-4" />
|
|
{oidcLoggingIn
|
|
? 'Redirecting…'
|
|
: oidcConfigured
|
|
? 'Switch account with Authentik'
|
|
: 'Login with Authentik'}
|
|
</Button>
|
|
|
|
<div class="flex items-center gap-3">
|
|
<Separator decorative class="flex-1" />
|
|
<span class="text-[10px] font-medium uppercase tracking-widest text-muted-foreground/40"
|
|
>or use</span
|
|
>
|
|
<Separator decorative class="flex-1" />
|
|
</div>
|
|
|
|
<form
|
|
class="flex flex-col gap-2.5"
|
|
onsubmit={(e) => {
|
|
e.preventDefault()
|
|
save()
|
|
}}
|
|
>
|
|
<div class="relative">
|
|
<LockIcon
|
|
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground/50"
|
|
/>
|
|
<Input type="password" placeholder="Bearer token" class="pl-9" bind:value={token} />
|
|
</div>
|
|
<Button type="submit" disabled={saving} class="w-full">
|
|
{saving ? 'Saving…' : 'Save'}
|
|
</Button>
|
|
</form>
|
|
|
|
{#if error}
|
|
<p class="rounded-lg bg-destructive/10 px-3 py-2 text-center text-sm text-destructive">
|
|
{error}
|
|
</p>
|
|
{/if}
|
|
|
|
{#if existing.token || oidcConfigured}
|
|
<div class="flex justify-center pt-1">
|
|
<Button type="button" variant="ghost" size="sm" onclick={forgetConnection}
|
|
>Forget saved connection</Button
|
|
>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else if section === 'appearance'}
|
|
<div class="mx-auto flex max-w-md flex-col gap-5">
|
|
<div>
|
|
<h2 class="text-sm font-semibold">Appearance</h2>
|
|
<p class="mt-0.5 text-xs text-muted-foreground">
|
|
Pick the theme for the whole desktop.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 gap-2">
|
|
{#each Object.entries(THEME_LABELS) as [id, label] (id)}
|
|
<button
|
|
type="button"
|
|
class="flex items-center justify-between gap-1.5 rounded-lg border px-2.5 py-1.5 text-left text-sm transition-colors {getTheme() ===
|
|
(id as Theme)
|
|
? 'border-primary/50 bg-primary/5 text-foreground'
|
|
: 'text-muted-foreground hover:bg-muted/50'}"
|
|
onclick={() => pickTheme(id as Theme)}
|
|
>
|
|
{label}
|
|
{#if getTheme() === (id as Theme)}<CheckIcon
|
|
class="size-3.5 shrink-0 text-primary"
|
|
/>{/if}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
|
|
<Separator decorative />
|
|
|
|
<div>
|
|
<h3 class="text-sm font-semibold">Desktop background</h3>
|
|
<p class="mt-0.5 text-xs text-muted-foreground">
|
|
A subtle CSS pattern behind your icons, in the style of
|
|
<a
|
|
href="https://www.magicpattern.design/tools/css-backgrounds"
|
|
target="_blank"
|
|
rel="noreferrer">magicpattern.design</a
|
|
>.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-4 gap-2">
|
|
{#each PATTERNS as p (p.id)}
|
|
{@const active = getBackground().pattern === p.id}
|
|
<button
|
|
type="button"
|
|
class="flex flex-col items-center gap-1.5 rounded-lg border p-2 transition-colors {active
|
|
? 'border-primary/60 bg-primary/5'
|
|
: 'border-border hover:bg-muted/50'}"
|
|
onclick={() => setBackgroundPattern(p.id)}
|
|
title={p.label}
|
|
>
|
|
<span
|
|
class="h-10 w-full rounded-md border border-border/60 bg-muted"
|
|
style={swatchStyle(p.id, getBackground().color)}
|
|
></span>
|
|
<span
|
|
class="flex items-center gap-1 text-[11px] {active
|
|
? 'font-medium text-foreground'
|
|
: 'text-muted-foreground'}"
|
|
>
|
|
{p.label}
|
|
{#if active}<CheckIcon class="size-3 text-primary" />{/if}
|
|
</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- Colors: pattern (foreground shapes) + an optional fill behind
|
|
them. Fill defaults to "Auto", i.e. transparent, so the app's
|
|
own theme background shows through the gaps — same as before
|
|
this control existed. -->
|
|
<div class="flex items-center justify-between gap-4">
|
|
<Label for="bg-color" class="text-xs font-medium">Pattern color</Label>
|
|
<div class="flex items-center gap-2">
|
|
<input
|
|
id="bg-color"
|
|
type="color"
|
|
class="size-7 cursor-pointer rounded-md border border-border bg-transparent p-0.5"
|
|
value={getBackground().color}
|
|
oninput={(e) => setPatternColor((e.currentTarget as HTMLInputElement).value)}
|
|
/>
|
|
<span class="font-mono text-xs text-muted-foreground">{getBackground().color}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between gap-4">
|
|
<div class="flex items-center gap-2">
|
|
<Checkbox
|
|
id="bg-fill-toggle"
|
|
checked={getBackground().fillColor !== null}
|
|
onCheckedChange={(v) => onFillToggle(!!v)}
|
|
/>
|
|
<Label for="bg-fill-toggle" class="text-xs font-medium">Custom background fill</Label>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<input
|
|
id="bg-fill-color"
|
|
type="color"
|
|
class="size-7 cursor-pointer rounded-md border border-border bg-transparent p-0.5 disabled:cursor-not-allowed disabled:opacity-40"
|
|
value={getBackground().fillColor ?? fillDraft}
|
|
disabled={getBackground().fillColor === null}
|
|
oninput={onFillColorInput}
|
|
/>
|
|
<span class="font-mono text-xs text-muted-foreground"
|
|
>{getBackground().fillColor ?? 'Auto'}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-1.5">
|
|
<div class="flex items-center justify-between">
|
|
<Label for="bg-opacity" class="text-xs font-medium">Opacity</Label>
|
|
<span class="font-mono text-xs text-muted-foreground"
|
|
>{Math.round(getBackground().opacity * 100)}%</span
|
|
>
|
|
</div>
|
|
<Slider
|
|
id="bg-opacity"
|
|
type="single"
|
|
min={0}
|
|
max={1}
|
|
step={0.01}
|
|
value={getBackground().opacity}
|
|
onValueChange={setBackgroundOpacity}
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-1.5">
|
|
<div class="flex items-center justify-between">
|
|
<Label for="bg-fade" class="text-xs font-medium">Fade mask</Label>
|
|
<span class="font-mono text-xs text-muted-foreground">
|
|
{getBackground().fade === 0 ? 'Off' : `${Math.round(getBackground().fade * 100)}%`}
|
|
</span>
|
|
</div>
|
|
<Slider
|
|
id="bg-fade"
|
|
type="single"
|
|
min={0}
|
|
max={1}
|
|
step={0.01}
|
|
value={getBackground().fade}
|
|
onValueChange={setBackgroundFade}
|
|
/>
|
|
<p class="text-[11px] text-muted-foreground">
|
|
Fades the pattern out toward the edges, like a vignette.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-1.5">
|
|
<div class="flex items-center justify-between">
|
|
<Label for="bg-scale" class="text-xs font-medium">Size</Label>
|
|
<span class="font-mono text-xs text-muted-foreground"
|
|
>{Math.round(getBackground().scale * 100)}%</span
|
|
>
|
|
</div>
|
|
<Slider
|
|
id="bg-scale"
|
|
type="single"
|
|
min={0.4}
|
|
max={3}
|
|
step={0.05}
|
|
value={getBackground().scale}
|
|
onValueChange={setBackgroundScale}
|
|
disabled={noPattern}
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-1.5">
|
|
<div class="flex items-center justify-between">
|
|
<Label for="bg-rotation" class="text-xs font-medium">Rotation</Label>
|
|
<span class="font-mono text-xs text-muted-foreground"
|
|
>{Math.round(getBackground().rotation)}°</span
|
|
>
|
|
</div>
|
|
<Slider
|
|
id="bg-rotation"
|
|
type="single"
|
|
min={0}
|
|
max={359}
|
|
step={1}
|
|
value={getBackground().rotation}
|
|
onValueChange={setBackgroundRotation}
|
|
disabled={noPattern}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="shrink-0 border-t px-3 py-1.5 text-right text-[11px] text-muted-foreground">
|
|
Oikos {VERSION}
|
|
</div>
|
|
</div>
|