Files
oikos/web/src/lib/components/desktop-shell/Desktop.svelte
dtoro 873b00ac42
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
style(web): fix prettier config, format entire web/ tree
.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>
2026-07-27 12:56:07 +02:00

153 lines
6.5 KiB
Svelte

<script lang="ts">
// The desktop shell: full-viewport surface (background + icons + the
// centered task launcher + the floating window layer) with the taskbar
// docked below it as a real flex sibling, not an overlay — so a maximized
// or dragged window can never end up underneath the taskbar. This replaces
// the old sidebar + hash-routed page shell in App.svelte entirely; apps are
// desktop icons now (see $lib/apps.ts), not nav items.
import { apps } from '$lib/apps'
import { iconPositions, resetIconLayout } from '$lib/stores/icons'
import { wm, openAppWindow, toggleShowDesktop } from '$lib/stores/windows'
import { summary } from '$lib/stores/context'
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'
import DockedLayer from './DockedLayer.svelte'
import Taskbar from './Taskbar.svelte'
import * as ContextMenu from '$lib/components/ui/context-menu'
import LayersIcon from '@lucide/svelte/icons/layers'
import Rows3Icon from '@lucide/svelte/icons/rows-3'
import MonitorIcon from '@lucide/svelte/icons/monitor'
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw'
import Undo2Icon from '@lucide/svelte/icons/undo-2'
import Redo2Icon from '@lucide/svelte/icons/redo-2'
// canUndo/canRedo are plain wmkit method calls (not stores), so they're
// snapshotted once when the menu opens (onOpenChange) rather than read
// reactively in the template. bits-ui auto-dismisses on item select and
// on Escape / click-away, so the old manual menuPos/closeMenu/runMenuAction
// machinery is gone.
let menuCanUndo = $state(false)
let menuCanRedo = $state(false)
function onOpenChange(open: boolean) {
if (!open) return
menuCanUndo = wm.canUndo()
menuCanRedo = wm.canRedo()
}
// Cmd/Ctrl+Z / Shift+Z for window-arrangement undo/redo (move, resize,
// close, ...) — wmkit tracks this history but ships no default keybinding.
// Skipped entirely while an editable element has focus so it never
// fights the browser's own text-undo inside the task input or a form
// field.
function onWindowKeydown(e: KeyboardEvent) {
const target = e.target as HTMLElement | null
const editable =
!!target &&
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
if (editable) return
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'z') return
e.preventDefault()
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">
{#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
so they (pointer-events-auto, later in DOM → paint on top) catch
their own right-clicks — the trigger only sees right-clicks that
fall through to bare desktop. This DOM-structure gate replaces the
old `currentTarget === target` event check. Left-click on bare
desktop blurs the focused window (the familiar "click empty
desktop to deselect" affordance). -->
<ContextMenu.Trigger class="absolute inset-0 z-0" onclick={() => wm.blur()}
></ContextMenu.Trigger>
<ContextMenu.Content class="min-w-48">
<ContextMenu.Item onSelect={() => wm.arrange('cascade')}>
<LayersIcon class="size-4" /> Cascade windows
</ContextMenu.Item>
<ContextMenu.Item onSelect={() => wm.arrange('tile')}>
<Rows3Icon class="size-4" /> Tile windows
</ContextMenu.Item>
<ContextMenu.Item onSelect={toggleShowDesktop}>
<MonitorIcon class="size-4" /> Show desktop
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item onSelect={resetIconLayout}>
<RotateCcwIcon class="size-4" /> Reset icon layout
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item disabled={!menuCanUndo} onSelect={() => wm.undo()}>
<Undo2Icon class="size-4" /> Undo
</ContextMenu.Item>
<ContextMenu.Item disabled={!menuCanRedo} onSelect={() => wm.redo()}>
<Redo2Icon class="size-4" /> Redo
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
<div class="pointer-events-none absolute inset-0 z-0">
{#each $apps as app (app.id)}
{@const pos = $iconPositions[app.id] ?? { col: 0, row: 0 }}
{@const badge = app.badge?.($summary) ?? 0}
<DesktopIcon {app} {pos} {badge} onOpen={() => openAppWindow(app.id)} />
{/each}
</div>
<div class="pointer-events-none absolute inset-0 z-10 flex items-center justify-center p-6">
<div class="pointer-events-auto">
<TaskLauncher />
</div>
</div>
<WindowLayer />
<DockedLayer />
</div>
<Taskbar />
</div>