Files
oikos/web/src/lib/components/desktop-shell/DesktopIcon.svelte
dtoro 757ef2f34b
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
feat(web): adopt cyberspace terminal aesthetic + dithered images
Rebrand light/dark themes to the cyberspace.online look: warm cream-on-black
palette (light/dark are exact inverses), self-hosted JetBrains Mono + VT323,
square corners, border-driven surfaces with no soft shadows. Adds a terminal
design-system CSS layer (DOS double-border modals with hatched corner, fg focus,
inversion-on-hover), a theme-aware <RasterImage> (Atkinson-dithered canvas with
img fallback), and unifies desktop icons, taskbar, window controls, pills and
links under one idiom. Pins window titlebars to a fixed height and switches chat
auto-scroll off scrollIntoView to avoid titlebar reflow.

VERSION 0.15.1 -> 0.16.0
2026-08-03 22:03:24 +02:00

110 lines
3.6 KiB
Svelte

<script lang="ts">
// A single desktop icon: positioned from the grid store, draggable to any
// free cell, opens its app on a plain click. wmkit has nothing to do with
// icons — they're a flat non-overlapping grid, not floating/resizable
// windows, so this is a small self-contained pointer-drag implementation
// rather than pressing wmkit's window abstractions into a shape they don't
// fit. See $lib/stores/icons.ts for the grid model + persistence.
import { GRID, iconPixelPos, placeIcon, type IconPos } from '$lib/stores/icons'
import type { AppDef } from '$lib/apps'
let {
app,
pos,
badge = 0,
onOpen
}: { app: AppDef; pos: IconPos; badge?: number; onOpen: () => void } = $props()
const DRAG_THRESHOLD = 5
let dragging = $state(false)
let dragPos = $state<{ x: number; y: number } | null>(null)
function toCell(x: number, y: number): IconPos {
return {
col: Math.round((x - GRID.padding) / (GRID.cell + GRID.gap)),
row: Math.round((y - GRID.padding) / (GRID.cell + GRID.gap))
}
}
function onPointerDown(e: PointerEvent) {
if (e.button !== 0) return
const el = e.currentTarget as HTMLElement
const startX = e.clientX
const startY = e.clientY
const origin = iconPixelPos(pos)
let moved = false
el.setPointerCapture(e.pointerId)
function onMove(ev: PointerEvent) {
const dx = ev.clientX - startX
const dy = ev.clientY - startY
if (!moved && Math.hypot(dx, dy) > DRAG_THRESHOLD) {
moved = true
dragging = true
}
if (moved) {
dragPos = { x: origin.x + dx, y: origin.y + dy }
}
}
function onUp() {
el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerup', onUp)
if (moved && dragPos) {
const cell = toCell(dragPos.x, dragPos.y)
placeIcon(app.id, cell.col, cell.row)
} else {
onOpen()
}
dragging = false
dragPos = null
}
el.addEventListener('pointermove', onMove)
el.addEventListener('pointerup', onUp)
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onOpen()
}
}
const restPos = $derived(iconPixelPos(pos))
const left = $derived(dragging && dragPos ? dragPos.x : restPos.x)
const top = $derived(dragging && dragPos ? dragPos.y : restPos.y)
</script>
<button
type="button"
class="group absolute flex flex-col items-center gap-1.5 p-1.5 pointer-events-auto select-none transition-transform focus-visible:outline-2 focus-visible:outline-ring {dragging
? 'z-50 cursor-grabbing'
: 'cursor-pointer hover:-translate-y-0.5'}"
style="left: {left}px; top: {top}px; width: {GRID.cell}px;"
onpointerdown={onPointerDown}
onkeydown={onKeydown}
title={app.title}
>
<span
class="relative flex size-11 items-center justify-center border transition-all {dragging
? 'border-foreground bg-foreground text-background shadow-[4px_4px_0_0_var(--foreground)]'
: 'border-border bg-card text-foreground group-hover:border-foreground group-hover:bg-foreground group-hover:text-background'}"
>
<app.icon class="size-5 transition-colors" />
{#if badge > 0}
<span
class="absolute -right-1.5 -top-1.5 flex h-4 min-w-4 items-center justify-center border border-background bg-destructive px-1 text-[10px] font-semibold text-destructive-foreground"
>
{badge > 99 ? '99+' : badge}
</span>
{/if}
</span>
<span
class="max-w-full truncate text-[10px] uppercase tracking-wider text-muted-foreground transition-colors group-hover:text-foreground"
>{app.title}</span
>
</button>