diff --git a/web/package-lock.json b/web/package-lock.json index f4ad82e..fa44f4d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,7 @@ "name": "oikos-web", "version": "0.1.0", "dependencies": { + "@surdeddd/wmkit": "^0.3.0", "clsx": "^2.1.1", "d3-force": "^3.0.0", "dompurify": "^3.4.11", @@ -876,7 +877,7 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -887,7 +888,7 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -898,7 +899,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -908,14 +909,14 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1282,11 +1283,44 @@ "win32" ] }, + "node_modules/@surdeddd/wmkit": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@surdeddd/wmkit/-/wmkit-0.3.0.tgz", + "integrity": "sha512-r5reUXN0Mcnx1nFjfB2grPCqpkH67S7UdaIY1bhyls5Tim4RyAAVscQSsimo1fOSqndZs9venjh3bueLJQVEMA==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@angular/core": ">=16", + "react": ">=18", + "solid-js": ">=1.8", + "svelte": ">=4", + "vue": ">=3.3" + }, + "peerDependenciesMeta": { + "@angular/core": { + "optional": true + }, + "react": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -1643,7 +1677,7 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -2032,7 +2066,7 @@ "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -2105,7 +2139,7 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -2132,7 +2166,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -2514,7 +2548,7 @@ "version": "5.8.1", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/dompurify": { @@ -2824,7 +2858,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/espree": { @@ -2862,7 +2896,7 @@ "version": "2.2.13", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -3330,7 +3364,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.6" @@ -3754,7 +3788,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/locate-path": { @@ -3808,7 +3842,7 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -4470,7 +4504,7 @@ "version": "5.56.4", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", @@ -6281,7 +6315,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "dev": true, + "devOptional": true, "license": "MIT" } } diff --git a/web/package.json b/web/package.json index 86490e4..435a850 100644 --- a/web/package.json +++ b/web/package.json @@ -40,6 +40,7 @@ "vitest": "^2.0.0" }, "dependencies": { + "@surdeddd/wmkit": "^0.3.0", "clsx": "^2.1.1", "d3-force": "^3.0.0", "dompurify": "^3.4.11", diff --git a/web/src/App.svelte b/web/src/App.svelte index 3836591..9b3b40e 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -8,6 +8,7 @@ import Knowledge from './pages/Knowledge.svelte' import Learning from './pages/Learning.svelte' import Config from './pages/Config.svelte' + import EntityDesktop from '$lib/components/EntityDesktop.svelte' import { newChat } from '$lib/stores/chat' import { summary, subscribeContext, openSignalCount } from '$lib/stores/context' import { currentTask } from '$lib/stores/workspace' @@ -249,4 +250,6 @@ + + {/if} diff --git a/web/src/app.css b/web/src/app.css index 0c5eb9b..16090f2 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -242,6 +242,45 @@ height: 100%; } +/* wmkit floating windows (EntityDesktop.svelte) — mapped onto the app's own + card/border/ring tokens instead of an imported wmkit theme, so windows + follow the terracotta/dark theme toggle for free. */ +[data-wm-desktop] { + overflow: visible; +} + +[data-wm-window] { + display: flex; + flex-direction: column; + box-sizing: border-box; + pointer-events: auto; + background: var(--card); + color: var(--card-foreground); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: 0 8px 24px oklch(0 0 0 / 0.18); + overflow: hidden; + outline: none; +} + +[data-wm-window][data-wm-focused] { + border-color: var(--ring); + box-shadow: 0 12px 32px oklch(0 0 0 / 0.28); +} + +[data-wm-window][data-wm-dragging], +[data-wm-window][data-wm-resizing] { + user-select: none; +} + +[data-wm-window][data-wm-stage='minimized'] { + display: none; +} + +[data-wm-resize] { + position: absolute; +} + a { color: var(--accent-blue); text-decoration: none; diff --git a/web/src/lib/components/DetailSection.svelte b/web/src/lib/components/DetailSection.svelte index 9009be3..346c25c 100644 --- a/web/src/lib/components/DetailSection.svelte +++ b/web/src/lib/components/DetailSection.svelte @@ -22,7 +22,7 @@ - + {title}{count !== undefined ? ` (${count})` : ''} -
+
{@render children()}
diff --git a/web/src/lib/components/EntityDesktop.svelte b/web/src/lib/components/EntityDesktop.svelte new file mode 100644 index 0000000..617b2de --- /dev/null +++ b/web/src/lib/components/EntityDesktop.svelte @@ -0,0 +1,46 @@ + + +
+ {#each $wmState.order as id (id)} + {@const win = $wmState.windows[id]} + {#if win} +
+
+ {win.title} +
+ + +
+
+
+ {#key id} + + {/key} +
+
+ {/if} + {/each} +
diff --git a/web/src/lib/components/EntityDetailContent.svelte b/web/src/lib/components/EntityDetailContent.svelte index 6aa5464..6a29358 100644 --- a/web/src/lib/components/EntityDetailContent.svelte +++ b/web/src/lib/components/EntityDetailContent.svelte @@ -244,7 +244,7 @@ } -
+
{#if loading} diff --git a/web/src/lib/components/EntitySheet.svelte b/web/src/lib/components/EntitySheet.svelte deleted file mode 100644 index 31d302b..0000000 --- a/web/src/lib/components/EntitySheet.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - {currentSlug ?? 'Entity detail'} - Entity detail panel - - {#if currentSlug} - {#key currentSlug} - (currentSlug = s)} /> - {/key} - {/if} - - diff --git a/web/src/lib/components/SessionGraph.svelte b/web/src/lib/components/SessionGraph.svelte index 106c033..fb7973c 100644 --- a/web/src/lib/components/SessionGraph.svelte +++ b/web/src/lib/components/SessionGraph.svelte @@ -13,12 +13,7 @@ import { fetchGraph, type Entity } from '$lib/api' import { messages } from '$lib/stores/chat' import { touched, healthDiffs } from '$lib/stores/workspace' - import { relativeTime } from '$lib/utils' - import { Badge } from '$lib/components/ui/badge' - import { Button } from '$lib/components/ui/button' - import EntitySheet from '$lib/components/EntitySheet.svelte' - import ExternalLinkIcon from '@lucide/svelte/icons/external-link' - import XIcon from '@lucide/svelte/icons/x' + import { openEntityWindow, wmState } from '$lib/stores/windows' interface Node extends Entity { x?: number @@ -46,8 +41,6 @@ let nodes = $state([]) let links = $state([]) let selected = $state(null) - let sheetSlug = $state(null) - let sheetOpen = $state(false) let sim: Simulation | null = null @@ -66,14 +59,6 @@ let cw = $state(300) let ch = $state(300) - // This component sits INSIDE one of TaskContextPanel's own resizable slots - // (Scope), so — unlike a top-level section — its total budget can change at - // any time from outside (dragging the outer Scope/Plan handle), including - // while the detail panel below is open. asideHeight tracks that live budget - // so detailHeight can self-clamp to it instead of trusting a one-time seed. - let asideEl = $state(null) - let asideHeight = $state(300) - function collectSlugs(value: unknown, out: Set) { if (typeof value === 'string') { const m = value.match(SLUG_RE) @@ -217,13 +202,12 @@ return () => ro.disconnect() }) + // The highlight ring/dim styling below is tied to the node whose window + // was last opened — once that window is closed (from EntityDesktop, not + // necessarily from here), the ring should go with it rather than pointing + // at a window that no longer exists. $effect(() => { - if (!asideEl) return - const ro = new ResizeObserver((entries) => { - asideHeight = Math.max(entries[0].contentRect.height, 1) - }) - ro.observe(asideEl) - return () => ro.disconnect() + if (selected && !$wmState.windows[selected.slug]) selected = null }) onDestroy(() => sim?.stop()) @@ -267,52 +251,11 @@ return typeof end === 'object' ? end.slug : end } - // ─── graph / detail resize ─────────────────────────────────────────── - // Same drag handle, same feel as TaskContextPanel's Scope/Plan/Activity - // split — but the graph side stays flex-1 (always auto-fills whatever's - // left) rather than tracking its own pixel number. Only detailHeight is - // explicit, and it's continuously clamped against asideHeight (this - // component's actual live budget) rather than a value seeded once — so - // resizing the OUTER Scope section while the detail panel is open can't - // push this panel past its container the way a one-time seed could. - const MIN_GRAPH = 80 - const MIN_DETAIL = 80 - const HANDLE = 6 - - let detailHeight = $state(200) - let resizing = $state(false) - let resizeStartY = $state(0) - let resizeStartH = $state(0) - - function maxDetailHeight(): number { - return Math.max(MIN_DETAIL, asideHeight - MIN_GRAPH - HANDLE) - } - - $effect(() => { - const max = maxDetailHeight() - if (detailHeight > max) detailHeight = max - }) - - function onPointerDown(e: PointerEvent) { - e.preventDefault() - resizing = true - resizeStartY = e.clientY - resizeStartH = detailHeight - window.addEventListener('pointermove', onPointerMove) - window.addEventListener('pointerup', onPointerUp) - } - function onPointerMove(e: PointerEvent) { - if (!resizing) return - const dy = e.clientY - resizeStartY - detailHeight = Math.min(maxDetailHeight(), Math.max(MIN_DETAIL, resizeStartH - dy)) - } - function onPointerUp() { - resizing = false - window.removeEventListener('pointermove', onPointerMove) - window.removeEventListener('pointerup', onPointerUp) - } - // ─── drag / select ─────────────────────────────────────────────────── + // A click (pointerdown+up with no movement in between) opens the entity + // straight in its own floating window (EntityDesktop) instead of a + // click-through mini-panel — `selected` now only drives the highlight/dim + // styling below, so you can see at a glance which node you last opened. let dragState: { node: Node; moved: boolean } | null = null function toLocal(clientX: number, clientY: number) { @@ -334,6 +277,10 @@ dragState.moved = true nodes = [...nodes] } + function selectAndOpen(node: Node) { + selected = node + openEntityWindow(node.slug) + } function onUp() { if (!dragState) return const { node, moved } = dragState @@ -341,15 +288,7 @@ node.fy = null sim?.alphaTarget(0) dragState = null - if (!moved) { - const wasNull = selected === null - const next = selected?.slug === node.slug ? null : node - // A reasonable starting size on first open — the clamp effect above - // keeps it honest against the live container size from here on, so - // this doesn't need to be exact. - if (next && wasNull) detailHeight = Math.min(maxDetailHeight(), Math.round(ch * 0.45)) - selected = next - } + if (!moved) selectAndOpen(node) } const selectedRelations = $derived( @@ -362,15 +301,9 @@ }) : [] ) - - function openFull() { - if (!selected) return - sheetSlug = selected.slug - sheetOpen = true - } -