feat(web): replace 3D graph with fleet map, add desktop background patterns, rename Knowledge Base to Fleet
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

- FleetMap: service-centric host -> container -> service graph replacing
  the WebGL 3D force graph, with health coloring, hover-to-trace blast
  radius, and click-to-open
- Desktop background: configurable CSS pattern picker in Settings ->
  Appearance (8 patterns, color/fill/opacity/fade/size/rotation),
  replacing the hardcoded ambient graph background
- Fix missing data-orientation/data-disabled Tailwind custom variants so
  the shadcn Slider's track actually renders
- Rename "Knowledge Base" app to "Fleet"; scope its table to the same
  fleet entities as the graph (compute-entity descendants + service)
  instead of all entities
- Remove dead code: EntityGraph, GraphBackground, categories.ts,
  MultiSelectFilter (all superseded by the above)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 20:04:53 +02:00
parent c151a66627
commit 4e4e2c169c
16 changed files with 1754 additions and 996 deletions

View File

@@ -1,504 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY, type Simulation } from 'd3-force'
import { fetchGraph, type GraphView, type Entity, type Health } from '$lib/api'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import { Skeleton } from '$lib/components/ui/skeleton'
export interface GraphInfo {
allRelTypes: string[]
relColors: Map<string, string>
visibleCount: number
truncated: boolean
zoomPct: number
}
let {
selectedSlug = null,
onSelect,
root = $bindable(''),
depth,
search,
reloadToken,
resetToken,
// Owned by the parent (shared with the entity table's type filter) —
// this graph only reads it to decide what's in focus, never writes it.
activeNodeTypes,
activeRelTypes = $bindable(new Set<string>()),
info = $bindable<GraphInfo>({ allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
}: {
selectedSlug?: string | null
onSelect: (slug: string | null) => void
root?: string
depth: number
search: string
// Bumped by the parent toolbar to request a data reload / view reset —
// these controls live in the shared page toolbar (not squeezed inside
// this resizable pane), so they can't call load()/resetView() directly.
reloadToken: number
resetToken: number
activeNodeTypes: Set<string>
activeRelTypes?: Set<string>
info?: GraphInfo
} = $props()
interface Node extends Entity {
x?: number
y?: number
vx?: number
vy?: number
fx?: number | null
fy?: number | null
degree: number
}
interface Link {
source: string | Node
target: string | Node
type: string
}
// SVG ids are document-global, not scoped to this <svg> — see
// SessionGraph.svelte's dotGridId for why this needs a per-instance suffix
// (also covers the per-relationship-type arrow markers below, which were
// keyed only by type name and would collide the same way across two
// mounted EntityGraph instances).
const uid = crypto.randomUUID().slice(0, 8)
const dotGridId = `dot-grid-${uid}`
let graph = $state<GraphView | null>(null)
let loading = $state(true)
let nodes = $state<Node[]>([])
let links = $state<Link[]>([])
let sim: Simulation<Node, Link> | null = null
let hoveredId = $state<string | null>(null)
// viewport transform: translate(x, y) scale(k)
let view = $state({ x: 0, y: 0, k: 1 })
let svgEl = $state<SVGSVGElement | null>(null)
const width = 1200
const height = 800
const healthColor: Record<Health, string> = {
healthy: '#3fb950',
degraded: '#d29922',
down: '#f85149',
unknown: '#8b949e'
}
const relPalette = ['#58a6ff', '#3fb950', '#d29922', '#f85149', '#bc8cff', '#39c5cf', '#f0883e', '#db61a2']
const relColorByType = $derived.by(() => {
const map = new Map<string, string>()
const types = Array.from(new Set(links.map((l) => l.type))).sort()
types.forEach((t, i) => map.set(t, relPalette[i % relPalette.length]))
return map
})
function relColor(type: string): string {
return relColorByType.get(type) ?? '#30363d'
}
function markerId(type: string): string {
return `arrow-${uid}-` + type.replace(/[^a-z0-9]/gi, '_')
}
function endpoint(end: string | Node): Node | undefined {
return typeof end === 'object' ? end : nodes.find((n) => n.id === end)
}
function endpointId(end: string | Node): string {
return typeof end === 'object' ? end.id : end
}
async function load() {
loading = true
graph = await fetchGraph({ root: root || undefined, depth, includeStatus: true })
loading = false
if (!graph) return
const byId = new Map(nodes.map((n) => [n.id, n]))
const degree = new Map<string, number>()
const idBySlug = new Map(graph.nodes.map((n) => [n.slug, n.id]))
for (const e of graph.edges) {
const s = idBySlug.get(e.source) ?? e.source
const t = idBySlug.get(e.target) ?? e.target
degree.set(s, (degree.get(s) ?? 0) + 1)
degree.set(t, (degree.get(t) ?? 0) + 1)
}
nodes = graph.nodes.map((n) => {
const prev = byId.get(n.id)
return { ...n, x: prev?.x, y: prev?.y, degree: degree.get(n.id) ?? 0 }
})
links = graph.edges.map((e) => ({
source: idBySlug.get(e.source) ?? e.source,
target: idBySlug.get(e.target) ?? e.target,
type: e.type
}))
// Edge-type toggles default to everything present — node-type toggles
// are owned by the parent (activeNodeTypes) and persist across reloads.
activeRelTypes = new Set(links.map((l) => l.type))
sim?.stop()
sim = forceSimulation(nodes)
.force('link', forceLink<Node, Link>(links).id((n) => n.id).distance(70).strength(0.6))
.force('charge', forceManyBody().strength(-240).distanceMax(400))
.force('center', forceCenter(width / 2, height / 2))
.force('collide', forceCollide<Node>((n) => nodeRadius(n) + 8))
.force('x', forceX(width / 2).strength(0.04))
.force('y', forceY(height / 2).strength(0.04))
.velocityDecay(0.32)
.alphaDecay(0.035)
.on('tick', () => {
nodes = [...nodes]
})
}
onMount(() => {
load()
const unsubscribe = subscribeEvents()
return () => {
unsubscribe()
sim?.stop()
}
})
onDestroy(() => sim?.stop())
$effect(() => {
const ev = $liveEvents[0]
if (!ev) return
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.') || ev.type === 'health.changed') {
load()
}
})
// Toolbar-driven reload/reset — mirrors the old onchange={load} behavior:
// typing freely doesn't refetch, only a committed change (Enter/blur in the
// parent's inputs, or the Reset button) bumps the token.
let lastReloadToken = $state(0)
$effect(() => {
if (reloadToken !== lastReloadToken) {
lastReloadToken = reloadToken
load()
}
})
let lastResetToken = $state(0)
$effect(() => {
if (resetToken !== lastResetToken) {
lastResetToken = resetToken
view = { x: 0, y: 0, k: 1 }
load()
}
})
function selectNode(node: Node) {
onSelect(node.slug)
}
function rerootTo(node: Node) {
root = node.slug
load()
}
function nodeColor(node: Node): string {
const h = graph?.health?.[node.id]
return h ? healthColor[h] : '#58a6ff'
}
function nodeRadius(node: Node): number {
return 5 + Math.min(Math.sqrt(node.degree) * 1.6, 7)
}
const allRelTypes = $derived(Array.from(new Set(links.map((l) => l.type))).sort())
// Publish status/legend info up to the parent toolbar.
$effect(() => {
info = {
allRelTypes,
relColors: relColorByType,
visibleCount: visibleNodeIds.size,
truncated: !!graph?.truncated,
zoomPct: Math.round(view.k * 100)
}
})
const matchedIds = $derived.by(() => {
if (!search.trim()) return null
const q = search.trim().toLowerCase()
return new Set(nodes.filter((n) => n.slug.toLowerCase().includes(q) || n.name.toLowerCase().includes(q)).map((n) => n.id))
})
// Focus = the shared type multiselect (activeNodeTypes) says this type is
// visible — same control the entity table filters its rows by.
const focusNodeIds = $derived(new Set(nodes.filter((n) => activeNodeTypes.has(n.type)).map((n) => n.id)))
// Real infra relationships mostly cross type lines (a service sits on a
// network, uses storage, runs on an lxc). Hard-hiding any edge whose
// other end isn't in the active type set left focus nodes looking like
// disconnected dots. Rooted views (the user is exploring out from one
// entity) pull in 1-hop neighbors of any type, dimmed, so the edges — and
// what they connect to — stay visible. Unscoped "browse everything" views
// (no root) skip this: with dozens of focus nodes that touch nearly
// everything, 1-hop expansion floods in most of the graph (measured: 417
// of 479 total entities for an unrooted Fleet-typed view) — worse than
// the isolated-dot problem it was meant to fix. There, same-type-only
// edges stay.
const neighborNodeIds = $derived.by(() => {
const neighbors = new Set<string>()
if (!root.trim()) return neighbors
for (const l of links) {
if (!activeRelTypes.has(l.type)) continue
const s = endpointId(l.source)
const t = endpointId(l.target)
if (focusNodeIds.has(s) && !focusNodeIds.has(t)) neighbors.add(t)
else if (focusNodeIds.has(t) && !focusNodeIds.has(s)) neighbors.add(s)
}
return neighbors
})
const visibleNodeIds = $derived(new Set([...focusNodeIds, ...neighborNodeIds]))
const selectedId = $derived(nodes.find((n) => n.slug === selectedSlug)?.id ?? null)
const adjacency = $derived.by(() => {
const adj = new Map<string, Set<string>>()
for (const l of links) {
const s = endpointId(l.source)
const t = endpointId(l.target)
if (!adj.has(s)) adj.set(s, new Set())
if (!adj.has(t)) adj.set(t, new Set())
adj.get(s)!.add(t)
adj.get(t)!.add(s)
}
return adj
})
const focusIds = $derived.by(() => {
const focus = hoveredId ?? selectedId
if (!focus) return null
const set = new Set<string>([focus])
for (const n of adjacency.get(focus) ?? []) set.add(n)
return set
})
function nodeOpacity(node: Node): number {
const base = focusNodeIds.has(node.id) ? 1 : 0.4
if (matchedIds !== null) return matchedIds.has(node.id) ? base : 0.1
if (focusIds !== null) return focusIds.has(node.id) ? 1 : Math.min(base, 0.15)
return base
}
function linkVisualState(link: Link): { opacity: number; emphasized: boolean } {
const s = endpointId(link.source)
const t = endpointId(link.target)
const focus = hoveredId ?? selectedId
if (focus && (s === focus || t === focus)) return { opacity: 0.95, emphasized: true }
if (focusIds !== null || matchedIds !== null) return { opacity: 0.08, emphasized: false }
return { opacity: 0.45, emphasized: false }
}
// ─── pan / zoom / drag ───────────────────────────────────────────────
function toViewBox(clientX: number, clientY: number): { x: number; y: number } {
const rect = svgEl!.getBoundingClientRect()
return {
x: ((clientX - rect.left) / rect.width) * width,
y: ((clientY - rect.top) / rect.height) * height
}
}
function toWorld(clientX: number, clientY: number): { x: number; y: number } {
const p = toViewBox(clientX, clientY)
return { x: (p.x - view.x) / view.k, y: (p.y - view.y) / view.k }
}
function onWheel(e: WheelEvent) {
e.preventDefault()
const factor = e.deltaY < 0 ? 1.18 : 1 / 1.18
const k = Math.min(6, Math.max(0.25, view.k * factor))
const p = toViewBox(e.clientX, e.clientY)
const wx = (p.x - view.x) / view.k
const wy = (p.y - view.y) / view.k
view = { k, x: p.x - wx * k, y: p.y - wy * k }
}
let panState = $state<{ startX: number; startY: number; viewX: number; viewY: number; moved: boolean } | null>(null)
let dragState: { node: Node; moved: boolean } | null = null
function onBackgroundPointerDown(e: PointerEvent) {
if (dragState) return
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
const p = toViewBox(e.clientX, e.clientY)
panState = { startX: p.x, startY: p.y, viewX: view.x, viewY: view.y, moved: false }
}
function onNodePointerDown(e: PointerEvent, node: Node) {
e.stopPropagation()
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
dragState = { node, moved: false }
sim?.alphaTarget(0.25).restart()
}
function onPointerMove(e: PointerEvent) {
if (dragState) {
const w = toWorld(e.clientX, e.clientY)
dragState.node.fx = w.x
dragState.node.fy = w.y
dragState.moved = true
return
}
if (panState) {
const p = toViewBox(e.clientX, e.clientY)
const dx = p.x - panState.startX
const dy = p.y - panState.startY
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) panState.moved = true
view = { ...view, x: panState.viewX + dx, y: panState.viewY + dy }
}
}
function onPointerUp(e: PointerEvent) {
if (dragState) {
const { node, moved } = dragState
node.fx = null
node.fy = null
sim?.alphaTarget(0)
dragState = null
if (!moved) selectNode(node)
return
}
if (panState && !panState.moved) {
// Plain click on empty background (not a drag-pan) — clear selection.
onSelect(null)
}
panState = null
}
</script>
{#if loading && !nodes.length}
<Skeleton class="h-full min-h-0" />
{:else}
<div class="relative h-full min-h-0 overflow-hidden rounded-lg border">
<svg
bind:this={svgEl}
viewBox="0 0 {width} {height}"
preserveAspectRatio="xMidYMid slice"
class="h-full w-full touch-none {panState ? 'cursor-grabbing' : 'cursor-grab'}"
role="application"
aria-label="Entity graph"
onwheel={onWheel}
onpointerdown={onBackgroundPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
>
<defs>
<pattern id={dotGridId} width="12" height="12" patternUnits="userSpaceOnUse">
<circle cx="2" cy="2" r="0.8" fill="var(--border)" opacity="0.75" />
</pattern>
{#each allRelTypes as type}
<marker id={markerId(type)} viewBox="0 -4 8 8" refX="8" refY="0" markerWidth="7" markerHeight="7" orient="auto">
<path d="M0,-3.5L8,0L0,3.5" fill={relColor(type)} />
</marker>
{/each}
</defs>
<rect x="0" y="0" width={width} height={height} fill="url(#{dotGridId})" />
<g transform="translate({view.x},{view.y}) scale({view.k})">
<g>
{#each links as link}
{@const s = endpoint(link.source)}
{@const t = endpoint(link.target)}
{#if s?.x != null && t?.x != null && s?.y != null && t?.y != null && activeRelTypes.has(link.type) && visibleNodeIds.has(s.id) && visibleNodeIds.has(t.id)}
{@const vs = linkVisualState(link)}
{@const dx = t.x - s.x}
{@const dy = t.y - s.y}
{@const len = Math.max(Math.hypot(dx, dy), 1)}
{@const curve = Math.min(len * 0.15, 40)}
{@const cx = (s.x + t.x) / 2 - (dy / len) * curve}
{@const cy = (s.y + t.y) / 2 + (dx / len) * curve}
{@const cdx = t.x - cx}
{@const cdy = t.y - cy}
{@const clen = Math.max(Math.hypot(cdx, cdy), 1)}
{@const tr = nodeRadius(t) + 3}
{@const ex = t.x - (cdx / clen) * tr}
{@const ey = t.y - (cdy / clen) * tr}
{@const mx = 0.25 * s.x + 0.5 * cx + 0.25 * ex}
{@const my = 0.25 * s.y + 0.5 * cy + 0.25 * ey}
<path
d="M {s.x},{s.y} Q {cx},{cy} {ex},{ey}"
fill="none"
stroke={relColor(link.type)}
stroke-width={vs.emphasized ? 2 : 1.2}
opacity={vs.opacity}
marker-end="url(#{markerId(link.type)})"
>
<title>{link.type}</title>
</path>
{#if vs.emphasized && view.k >= 0.7}
<text
x={mx}
y={my - 4}
text-anchor="middle"
font-size={10 / view.k}
fill={relColor(link.type)}
opacity="0.95"
paint-order="stroke"
stroke="var(--background)"
stroke-width={3 / view.k}
>
{link.type}
</text>
{/if}
{/if}
{/each}
</g>
<g>
{#each nodes as node (node.id)}
{#if node.x != null && node.y != null && visibleNodeIds.has(node.id)}
{@const r = nodeRadius(node)}
{@const op = nodeOpacity(node)}
{@const isFocus = hoveredId === node.id || selectedId === node.id}
{@const isMatch = matchedIds !== null && matchedIds.has(node.id)}
<g
transform="translate({node.x},{node.y})"
opacity={op}
class="cursor-pointer"
role="button"
tabindex="0"
onpointerdown={(e) => onNodePointerDown(e, node)}
onpointerenter={() => (hoveredId = node.id)}
onpointerleave={() => (hoveredId = null)}
onkeydown={(e) => e.key === 'Enter' && selectNode(node)}
ondblclick={() => rerootTo(node)}
>
{#if isFocus || isMatch}
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
{/if}
<circle r={r} fill={nodeColor(node)} stroke={isFocus || isMatch ? 'var(--foreground)' : 'var(--background)'} stroke-width={isFocus || isMatch ? 2 : 1.25} />
{#if view.k >= 0.8 || isFocus || isMatch || op === 1 && focusIds !== null}
<text
y={r + 12}
text-anchor="middle"
font-size={isFocus ? 12 / view.k : 10 / Math.max(view.k, 1)}
fill={isFocus ? 'var(--foreground)' : 'var(--muted-foreground)'}
paint-order="stroke"
stroke="var(--background)"
stroke-width={3 / view.k}
class="pointer-events-none select-none"
>
{node.slug}
</text>
{/if}
</g>
{/if}
{/each}
</g>
</g>
</svg>
<div class="pointer-events-none absolute bottom-2 left-2 rounded bg-background/80 px-2 py-1 text-[10px] text-muted-foreground">
scroll to zoom · drag background to pan · drag nodes · click to inspect · double-click to re-root
</div>
</div>
{/if}

File diff suppressed because it is too large Load Diff

View File

@@ -1,266 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte'
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, type Simulation } from 'd3-force'
import { fetchGraph, type Health } from '$lib/api'
import { getTheme } from '$lib/stores/theme.svelte'
// Ambient, non-interactive knowledge-graph backdrop. Purely decorative: the
// host places this behind the page with pointer-events:none, so it never
// steals clicks. The "alive" feeling comes entirely from the camera (slow
// autonomous drift + mouse parallax + per-node depth), NOT from a live force
// sim — we warm the layout up once, freeze it, then just pan a static field.
interface SimNode {
id: string
slug: string
degree: number
z: number // depth in [0,1] for parallax
x?: number
y?: number
fx?: number | null
fy?: number | null
}
interface SimLink {
source: string | SimNode
target: string | SimNode
}
let host = $state<HTMLDivElement | null>(null)
let canvas = $state<HTMLCanvasElement | null>(null)
let nodes: SimNode[] = []
let links: SimLink[] = []
let health: Record<string, Health> = {}
// World bounds the layout is centered in; camera pans within.
const WORLD = 1400
const MAX_NODES = 260
const healthColor: Record<Health, string> = {
healthy: '#3fb950',
degraded: '#d29922',
down: '#f85149',
unknown: '#8b949e'
}
function nodeRadius(n: SimNode): number {
return 3 + Math.min(Math.sqrt(n.degree) * 1.4, 7)
}
async function loadGraph() {
const graph = await fetchGraph({ depth: 3, includeStatus: true })
if (!graph) return
health = graph.health ?? {}
// degree by id, edges reference slugs
const idBySlug = new Map(graph.nodes.map((n) => [n.slug, n.id]))
const degree = new Map<string, number>()
for (const e of graph.edges) {
const s = idBySlug.get(e.source) ?? e.source
const t = idBySlug.get(e.target) ?? e.target
degree.set(s, (degree.get(s) ?? 0) + 1)
degree.set(t, (degree.get(t) ?? 0) + 1)
}
let all: SimNode[] = graph.nodes.map((n) => ({
id: n.id,
slug: n.slug,
degree: degree.get(n.id) ?? 0,
z: Math.random()
}))
// Cap to the most-connected nodes so large graphs stay cheap.
if (all.length > MAX_NODES) {
all = [...all].sort((a, b) => b.degree - a.degree).slice(0, MAX_NODES)
}
const keep = new Set(all.map((n) => n.id))
nodes = all
links = graph.edges
.map((e) => ({ source: idBySlug.get(e.source) ?? e.source, target: idBySlug.get(e.target) ?? e.target }))
.filter((l) => keep.has(l.source as string) && keep.has(l.target as string))
warmUpLayout()
}
// Run the sim to a settled state without rendering each tick, then freeze.
function warmUpLayout() {
const sim: Simulation<SimNode, SimLink> = forceSimulation(nodes)
.force('link', forceLink<SimNode, SimLink>(links).id((n) => n.id).distance(60).strength(0.5))
.force('charge', forceManyBody().strength(-140).distanceMax(360))
.force('center', forceCenter(0, 0))
.force('collide', forceCollide<SimNode>((n) => nodeRadius(n) + 6))
.stop()
const ticks = Math.min(400, Math.max(120, nodes.length * 2))
for (let i = 0; i < ticks; i++) sim.tick()
sim.stop()
}
// ─── camera + render loop ───────────────────────────────────────────────
let cam = { x: 0, y: 0 } // eased mouse-parallax offset
let targetCam = { x: 0, y: 0 }
let timer: ReturnType<typeof setTimeout> | 0 = 0
let dpr = 1
let w = 0
let h = 0
let dotCanvas: HTMLCanvasElement | null = null
let lastDotDark: boolean | null = null
function drawDots(dark: boolean) {
if (!dotCanvas) {
dotCanvas = document.createElement('canvas')
}
dotCanvas.width = Math.round(w * dpr)
dotCanvas.height = Math.round(h * dpr)
const dctx = dotCanvas.getContext('2d')!
dctx.setTransform(dpr, 0, 0, dpr, 0, 0)
dctx.clearRect(0, 0, w, h)
dctx.fillStyle = dark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.12)'
const spacing = 12
for (let x = spacing; x < w; x += spacing) {
for (let y = spacing; y < h; y += spacing) {
dctx.beginPath()
dctx.arc(x, y, 0.7, 0, Math.PI * 2)
dctx.fill()
}
}
}
function onPointerMove(e: PointerEvent) {
if (!host) return
const rect = host.getBoundingClientRect()
const nx = (e.clientX - rect.left) / rect.width - 0.5 // -0.5..0.5
const ny = (e.clientY - rect.top) / rect.height - 0.5
targetCam = { x: -nx * 90, y: -ny * 90 } // small parallax nudge
}
function resize() {
if (!host || !canvas) return
dpr = Math.min(window.devicePixelRatio || 1, 2)
w = host.clientWidth
h = host.clientHeight
canvas.width = Math.round(w * dpr)
canvas.height = Math.round(h * dpr)
dotCanvas = null // force redraw on next frame
}
function colorForNode(n: SimNode): string {
return healthColor[health[n.id] ?? 'unknown']
}
// Driven by setTimeout rather than requestAnimationFrame: some embedding
// contexts (iframed previews, backgrounded-but-visible panes) report
// document.hidden = true and browsers fully suspend rAF callbacks there,
// which would freeze this canvas forever. setTimeout keeps ticking
// regardless, and ~30fps is plenty for a slow ambient drift.
function draw(t: number) {
timer = setTimeout(() => draw(performance.now()), 33)
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
// ease parallax toward target
cam.x += (targetCam.x - cam.x) * 0.05
cam.y += (targetCam.y - cam.y) * 0.05
// autonomous drift (Lissajous pan + breathing zoom)
const ts = t / 1000
const driftX = Math.sin(ts * 0.05) * 70 + Math.sin(ts * 0.017) * 40
const driftY = Math.cos(ts * 0.043) * 60 + Math.sin(ts * 0.023) * 30
const zoom = 0.82 + Math.sin(ts * 0.03) * 0.03
const dark = getTheme() !== 'light'
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, w, h)
if (lastDotDark !== dark) { dotCanvas = null; lastDotDark = dark }
if (!dotCanvas) drawDots(dark)
ctx.drawImage(dotCanvas!, 0, 0)
const cx = w / 2
const cy = h / 2
// project a world point to screen, applying per-depth parallax
function project(px: number, py: number, z: number) {
const par = 0.5 + z // nearer nodes (higher z) move more
const ox = (driftX + cam.x) * par
const oy = (driftY + cam.y) * par
return { x: cx + (px + ox) * zoom, y: cy + (py + oy) * zoom }
}
// edges
ctx.lineWidth = 1
ctx.strokeStyle = dark ? 'rgba(140,175,230,0.28)' : 'rgba(60,90,140,0.22)'
ctx.beginPath()
for (const l of links) {
const s = l.source as SimNode
const tg = l.target as SimNode
if (s.x == null || tg.x == null) continue
const z = (s.z + tg.z) / 2
const a = project(s.x, s.y!, z)
const b = project(tg.x, tg.y!, z)
const dx = b.x - a.x
const dy = b.y - a.y
const len = Math.max(Math.hypot(dx, dy), 1)
const curve = Math.min(len * 0.15, 40)
const mx = (a.x + b.x) / 2 - (dy / len) * curve
const my = (a.y + b.y) / 2 + (dx / len) * curve
ctx.moveTo(a.x, a.y)
ctx.quadraticCurveTo(mx, my, b.x, b.y)
}
ctx.stroke()
// nodes (glow via radial gradient, cheap enough at this count)
for (const n of nodes) {
if (n.x == null || n.y == null) continue
const p = project(n.x, n.y, n.z)
const r = nodeRadius(n) * zoom * (0.7 + n.z * 0.6)
const col = colorForNode(n)
const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r * 3.2)
glow.addColorStop(0, hexA(col, dark ? 0.45 : 0.32))
glow.addColorStop(1, hexA(col, 0))
ctx.fillStyle = glow
ctx.beginPath()
ctx.arc(p.x, p.y, r * 3.2, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = hexA(col, dark ? 0.7 : 0.55)
ctx.beginPath()
ctx.arc(p.x, p.y, r, 0, Math.PI * 2)
ctx.fill()
}
// legibility scrim: dim only the center band where the UI sits, taper to
// ~nothing at the edges so the graph (and its connections) stay visible
// in the margins instead of being crushed everywhere equally.
const scrim = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.hypot(cx, cy))
const base = dark ? '13,17,23' : '255,255,255'
scrim.addColorStop(0, `rgba(${base},0.68)`)
scrim.addColorStop(0.45, `rgba(${base},0.32)`)
scrim.addColorStop(1, `rgba(${base},0.02)`)
ctx.fillStyle = scrim
ctx.fillRect(0, 0, w, h)
}
// "#rrggbb" + alpha -> rgba()
function hexA(hex: string, a: number): string {
const n = parseInt(hex.slice(1), 16)
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`
}
onMount(() => {
loadGraph()
resize()
const ro = new ResizeObserver(resize)
if (host) ro.observe(host)
window.addEventListener('pointermove', onPointerMove)
timer = setTimeout(() => draw(performance.now()), 33)
return () => {
clearTimeout(timer)
ro.disconnect()
window.removeEventListener('pointermove', onPointerMove)
}
})
</script>
<div bind:this={host} class="pointer-events-none absolute inset-0 overflow-hidden">
<canvas bind:this={canvas} class="h-full w-full"></canvas>
</div>

View File

@@ -1,64 +0,0 @@
<script lang="ts">
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'
import { Button } from '$lib/components/ui/button'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
let {
label,
options,
selected = $bindable(),
colorFor
}: {
label: string
options: string[]
selected: Set<string>
colorFor?: (option: string) => string
} = $props()
function toggle(opt: string) {
const next = new Set(selected)
if (next.has(opt)) next.delete(opt)
else next.add(opt)
selected = next
}
const allSelected = $derived(options.length > 0 && options.every((o) => selected.has(o)))
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" size="sm" class="h-8 gap-1.5">
{label}
<span class="text-muted-foreground">{selected.size}/{options.length}</span>
<ChevronDownIcon class="size-3.5 text-muted-foreground" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content class="max-h-80 w-56 overflow-y-auto" align="start">
<DropdownMenu.Item
closeOnSelect={false}
onSelect={() => { selected = allSelected ? new Set() : new Set(options) }}
class="text-xs text-muted-foreground"
>
{allSelected ? 'Deselect all' : 'Select all'}
</DropdownMenu.Item>
<DropdownMenu.Separator />
{#each options as opt}
<DropdownMenu.CheckboxItem
closeOnSelect={false}
checked={selected.has(opt)}
onCheckedChange={() => toggle(opt)}
class="text-xs"
>
{#if colorFor}
<span class="size-2 shrink-0 rounded-full" style="background: {colorFor(opt)}"></span>
{/if}
{opt}
</DropdownMenu.CheckboxItem>
{/each}
{#if options.length === 0}
<p class="px-2 py-1.5 text-xs text-muted-foreground">No types loaded yet.</p>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>

View File

@@ -9,7 +9,8 @@
import { iconPositions, resetIconLayout } from '$lib/stores/icons'
import { wm, openAppWindow, toggleShowDesktop } from '$lib/stores/windows'
import { summary } from '$lib/stores/context'
import GraphBackground from '../GraphBackground.svelte'
import { getBackground } from '$lib/stores/background.svelte'
import { patternCss } from '$lib/desktop-patterns'
import DesktopIcon from './DesktopIcon.svelte'
import TaskLauncher from './TaskLauncher.svelte'
import WindowLayer from './WindowLayer.svelte'
@@ -51,13 +52,42 @@
if (e.shiftKey) wm.redo()
else wm.undo()
}
// Configurable in Settings → Appearance (see background.svelte.ts). Two
// layers, not one, because rotation and the fade mask need different
// geometry:
// - outer: exactly the viewport box. Carries the fade mask, since a
// vignette has to be centered on what's actually visible.
// - inner: oversized (200%) and centered before rotating, so turning the
// pattern doesn't pull its straight edges into view at the corners —
// a viewport-sized box rotated in place would do exactly that.
const bgActive = $derived.by(() => {
const bg = getBackground()
return bg.pattern !== 'none' || bg.fillColor !== null
})
const bgOuterStyle = $derived.by(() => {
const bg = getBackground()
if (bg.fade <= 0) return ''
const stop = Math.round(100 - bg.fade * 70)
const mask = `radial-gradient(circle at 50% 50%, black 0%, black ${stop}%, transparent 100%)`
return `mask-image:${mask};-webkit-mask-image:${mask};`
})
const bgInnerStyle = $derived.by(() => {
const bg = getBackground()
const css = patternCss(bg.pattern, bg.color, bg.scale)
return `inset:-50%;width:200%;height:200%;opacity:${bg.opacity};background-color:${bg.fillColor ?? 'transparent'};transform:rotate(${bg.rotation}deg);${css}`
})
</script>
<svelte:window onkeydown={onWindowKeydown} />
<div class="fixed inset-0 flex flex-col">
<div class="relative min-h-0 flex-1 overflow-hidden" role="presentation">
<GraphBackground />
{#if bgActive}
<div class="pointer-events-none absolute inset-0 z-0 overflow-hidden" aria-hidden="true" style={bgOuterStyle}>
<div class="absolute" style={bgInnerStyle}></div>
</div>
{/if}
<ContextMenu.Root {onOpenChange}>
<!-- The bare-desktop hit area. Placed before the icons/windows layers

View File

@@ -32,6 +32,22 @@
if (appId && !idx.has(appId)) wm.close(id)
}
})
// Keep an open app window's title in sync with its registry entry. The
// title is copied into the window at open time and then persisted, so a
// rename (e.g. "Knowledge Base" -> "Fleet") would otherwise stay stuck in
// the titlebar/taskbar of any already-open or hydrated window until it was
// closed and reopened. Mirrors the task-window title sync in windows.ts.
$effect(() => {
const idx = $appById
for (const id of $wmState.order) {
const appId = appIdFromWindowId(id)
const app = appId ? idx.get(appId) : undefined
if (app && $wmState.windows[id]?.title !== app.title) {
wm.update(id, { title: app.title })
}
}
})
</script>
<div use:dk.desktop class="absolute inset-0 z-40 pointer-events-none">

View File

@@ -0,0 +1,7 @@
import Root from "./slider.svelte";
export {
Root,
//
Root as Slider,
};

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import { Slider as SliderPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
value = $bindable(),
orientation = "horizontal",
class: className,
...restProps
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> = $props();
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<SliderPrimitive.Root
bind:ref
bind:value={value as never}
data-slot="slider"
{orientation}
class={cn(
"data-vertical:min-h-40 relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:w-auto data-vertical:flex-col",
className
)}
{...restProps}
>
{#snippet children({ thumbItems })}
<span
data-slot="slider-track"
data-orientation={orientation}
class={cn(
"bg-muted rounded-full data-horizontal:h-1.5 data-horizontal:w-full data-vertical:h-full data-vertical:w-1.5 bg-muted relative grow overflow-hidden data-horizontal:w-full data-vertical:h-full"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
class={cn(
"bg-primary absolute select-none data-horizontal:h-full data-vertical:w-full"
)}
/>
</span>
{#each thumbItems as thumb (thumb.index)}
<SliderPrimitive.Thumb
data-slot="slider-thumb"
index={thumb.index}
class="border-primary ring-ring/50 size-4 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden block shrink-0 select-none disabled:pointer-events-none disabled:opacity-50"
/>
{/each}
{/snippet}
</SliderPrimitive.Root>