feat(web): adopt cyberspace terminal aesthetic + dithered images
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

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
This commit is contained in:
2026-08-03 22:03:24 +02:00
parent b27e1bf3ec
commit 757ef2f34b
18 changed files with 554 additions and 139 deletions

View File

@@ -59,7 +59,6 @@
} = $props()
let input = $state(typeof initialDraft === 'string' ? initialDraft : '')
let messagesEnd = $state<HTMLDivElement | null>(null)
let scrolledUp = $state(false)
let container = $state<HTMLDivElement | null>(null)
@@ -150,12 +149,17 @@
}
// Auto-scroll to bottom on new messages (or a freshly-raised question) —
// unless user scrolled up to read.
// unless user scrolled up to read. Sets scrollTop on the messages container
// directly instead of `scrollIntoView`, which walks ancestors and forces a
// reflow that can momentarily perturb the window titlebar height.
$effect(() => {
void messages
void question
if (streaming || !scrolledUp) {
setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
setTimeout(
() => container?.scrollTo({ top: container.scrollHeight, behavior: 'smooth' }),
50
)
}
})
@@ -326,7 +330,6 @@
{#if question}
<OperatorQuestion {sessionId} {question} />
{/if}
<div bind:this={messagesEnd}></div>
</div>
</div>

View File

@@ -106,7 +106,7 @@
// pulse brightness
const alpha = p.pulse * (0.35 + 0.15 * Math.sin(t * 1.2 + p.phase))
ctx.fillStyle = dark ? `rgba(140,175,230,${alpha})` : `rgba(60,90,140,${alpha})`
ctx.fillStyle = dark ? `rgba(168,153,132,${alpha})` : `rgba(58,58,58,${alpha})`
ctx.beginPath()
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2)
ctx.fill()
@@ -123,7 +123,7 @@
const dist = dx * dx + dy * dy
if (dist < CONNECT_DIST * CONNECT_DIST) {
const alpha = (1 - Math.sqrt(dist) / CONNECT_DIST) * 0.18
ctx.strokeStyle = dark ? `rgba(140,175,230,${alpha})` : `rgba(60,90,140,${alpha})`
ctx.strokeStyle = dark ? `rgba(168,153,132,${alpha})` : `rgba(58,58,58,${alpha})`
ctx.beginPath()
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
@@ -136,7 +136,7 @@
const cx = w / 2,
cy = h / 2
const scrim = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.hypot(cx, cy))
const base = dark ? '13,17,23' : '255,255,255'
const base = dark ? '0,0,0' : '239,229,192'
scrim.addColorStop(0, `rgba(${base},0.72)`)
scrim.addColorStop(0.35, `rgba(${base},0.40)`)
scrim.addColorStop(0.65, `rgba(${base},0.08)`)

View File

@@ -1101,7 +1101,7 @@
font-size: 12px;
font-weight: 500;
border: 1px solid var(--border);
border-radius: 999px;
border-radius: 0;
background: transparent;
color: var(--muted-foreground);
cursor: pointer;
@@ -1114,9 +1114,12 @@
border-color: var(--primary);
}
.chip.on {
color: var(--foreground);
background: var(--secondary);
border-color: var(--primary);
color: var(--background);
background: var(--foreground);
border-color: var(--foreground);
}
.chip.on b {
color: var(--background);
}
.chip .dot {
width: 8px;

View File

@@ -0,0 +1,206 @@
<script lang="ts" module>
// Cached dither result for the current source/size, so a theme change only
// re-paints (cheap) instead of re-running the error-diffusion pass.
export interface DitherCache {
key: string
bits: Uint8Array // 1 = light (paper), 0 = dark (ink)
alpha: Uint8Array
w: number
h: number
}
</script>
<script lang="ts">
import { onMount } from 'svelte'
let {
src,
alt = '',
width = 256,
class: className = '',
plain = false,
bias = 0
}: {
src: string
alt?: string
/** CSS display width in px. The image is dithered at this resolution. */
width?: number
class?: string
/** Skip dithering; render the crisp source <img> instead. */
plain?: boolean
/** -255..255. Positive → more pixels resolve to ink (foreground). */
bias?: number
} = $props()
let canvas = $state<HTMLCanvasElement | null>(null)
let imgEl = $state<HTMLImageElement | null>(null)
let loaded = $state(false)
let tainted = $state(false)
let cache: DitherCache | null = null
const showSkeleton = $derived(!loaded)
const showCanvas = $derived(loaded && !plain && !tainted)
function readRgb(varName: string): [number, number, number] {
const raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim()
const m = raw.match(/#([0-9a-fA-F]{6})/)
const hex = m ? m[1] : '000000'
return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)]
}
function paint() {
if (!cache || !canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const { bits, alpha, w, h } = cache
canvas.width = w
canvas.height = h
// dark source (ink) → foreground; light source (paper) → background
const fg = readRgb('--foreground')
const bg = readRgb('--background')
const out = ctx.createImageData(w, h)
const d = out.data
for (let p = 0, i = 0; p < w * h; p++, i += 4) {
if (alpha[p] < 64) {
d[i + 3] = 0
continue
}
const c = bits[p] ? bg : fg
d[i] = c[0]
d[i + 1] = c[1]
d[i + 2] = c[2]
d[i + 3] = 255
}
ctx.putImageData(out, 0, 0)
}
function process(img: HTMLImageElement) {
const nw = img.naturalWidth || img.width
const nh = img.naturalHeight || img.height
if (!nw || !nh) return
const w = Math.max(1, Math.round(width))
const h = Math.max(1, Math.round((w * nh) / nw))
const off = document.createElement('canvas')
off.width = w
off.height = h
const octx = off.getContext('2d', { willReadFrequently: true })
if (!octx) return
octx.drawImage(img, 0, 0, w, h)
let data: Uint8ClampedArray
try {
data = octx.getImageData(0, 0, w, h).data
} catch {
tainted = true
return
}
const n = w * h
const lum = new Float32Array(n)
const alpha = new Uint8Array(n)
for (let p = 0, i = 0; p < n; p++, i += 4) {
lum[p] = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]
alpha[p] = data[i + 3]
}
const thr = 128 - bias
const bits = new Uint8Array(n)
// Atkinson error diffusion: 6 neighbors each get 1/8 of the quantization
// error. Softer & more "screen-printed" than FloydSteinberg.
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const idx = y * w + x
const oldv = lum[idx]
const newv = oldv < thr ? 0 : 255
bits[idx] = newv === 255 ? 1 : 0
const e = (oldv - newv) / 8
if (x + 1 < w) lum[idx + 1] += e
if (x + 2 < w) lum[idx + 2] += e
if (y + 1 < h) {
if (x - 1 >= 0) lum[idx + w - 1] += e
lum[idx + w] += e
if (x + 1 < w) lum[idx + w + 1] += e
}
if (y + 2 < h) lum[idx + 2 * w] += e
}
}
cache = { key: src + w, bits, alpha, w, h }
tainted = false
paint()
}
function onLoad() {
loaded = true
}
// Re-dither when the source image, target width, or plain flag changes.
$effect(() => {
void src
void width
void plain
if (!loaded || !imgEl || plain || tainted) return
if (imgEl.complete && imgEl.naturalWidth > 0) process(imgEl)
})
onMount(() => {
// Theme flip changes --foreground/--background on <html>'s class; re-paint
// the cached bits with the new palette (no re-dither needed).
const mo = new MutationObserver(() => {
if (cache && !plain) paint()
})
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme'] })
return () => mo.disconnect()
})
</script>
<div class="raster-wrap {className}" style="--rw:{width}px">
{#if showSkeleton}
<div class="raster-skeleton" aria-hidden="true"></div>
{/if}
{#if !plain}
<canvas
bind:this={canvas}
class="raster-canvas"
class:hidden={!showCanvas}
role="img"
aria-label={alt}
></canvas>
{/if}
<img
bind:this={imgEl}
{src}
{alt}
class="raster-fallback"
class:hidden={showCanvas}
onload={onLoad}
onerror={() => {
tainted = true
loaded = true
}}
/>
</div>
<style>
.raster-wrap {
display: inline-block;
position: relative;
width: var(--rw);
line-height: 0;
}
.raster-canvas,
.raster-fallback {
display: block;
width: 100%;
height: auto;
image-rendering: pixelated;
}
.raster-canvas.hidden,
.raster-fallback.hidden {
display: none;
}
/* No-flash placeholder while the source decodes — matches cyberspace's
raster-image-skeleton (empty, fills with the theme background). */
.raster-skeleton {
width: 100%;
min-height: calc(var(--rw) * 0.6);
aspect-ratio: 1 / 1;
background: var(--background);
}
</style>

View File

@@ -80,25 +80,30 @@
<button
type="button"
class="group absolute flex flex-col items-center gap-1 rounded-lg p-1.5 pointer-events-auto select-none focus-visible:outline-2 focus-visible:outline-ring {dragging
? 'z-50 cursor-grabbing bg-accent/40'
: 'cursor-pointer hover:bg-accent/30'}"
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-10 items-center justify-center rounded-xl border bg-card/80 text-foreground shadow-sm backdrop-blur"
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" />
<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 rounded-full bg-destructive px-1 text-[10px] font-semibold text-destructive-foreground"
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-[11px] text-foreground/90">{app.title}</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>

View File

@@ -53,10 +53,10 @@
}
</script>
<div class="flex h-11 shrink-0 items-center gap-1.5 border-t bg-muted/30 px-2">
<div class="flex h-11 shrink-0 items-center gap-1.5 border-t border-border bg-background px-2">
<button
type="button"
class="flex shrink-0 items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
class="flex shrink-0 items-center justify-center border border-border p-1.5 text-muted-foreground transition-colors hover:border-foreground hover:bg-foreground hover:text-background"
onclick={toggleShowDesktop}
title="Show desktop"
>
@@ -65,7 +65,7 @@
<div class="h-6 w-px shrink-0 bg-border"></div>
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto py-1.5">
{#each buttons as win (win.id)}
{@const Icon = iconFor(win.id)}
{@const badge = badgeFor(win.id)}
@@ -73,12 +73,12 @@
<button
type="button"
data-taskbar-btn={win.id}
class="flex h-8 max-w-56 items-center gap-1.5 rounded-md border px-2 font-mono text-xs transition-colors {$wmState.focusedId ===
class="flex h-8 max-w-56 items-center gap-1.5 border px-2 font-mono text-xs transition-colors {$wmState.focusedId ===
win.id && win.stage !== 'minimized'
? 'border-primary/50 bg-primary/10 text-foreground'
: 'border-transparent bg-card/60 text-muted-foreground hover:bg-muted'} {win.stage ===
? 'border-foreground bg-foreground text-background'
: 'border-border bg-background text-muted-foreground hover:border-foreground hover:bg-foreground hover:text-background'} {win.stage ===
'minimized'
? 'opacity-60'
? 'opacity-50'
: ''}"
onclick={() => toggle(win.id, win)}
title={win.title}
@@ -87,7 +87,7 @@
<span class="min-w-0 truncate">{truncateMiddle(win.title, 26)}</span>
{#if badge > 0}
<span
class="flex h-3.5 min-w-3.5 shrink-0 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-semibold text-destructive-foreground"
class="flex h-3.5 min-w-3.5 shrink-0 items-center justify-center border border-background bg-destructive px-1 text-[9px] font-semibold text-destructive-foreground"
>
{badge > 99 ? '99+' : badge}
</span>
@@ -95,7 +95,7 @@
</button>
<button
type="button"
class="absolute -top-1.5 -right-1.5 hidden size-4 items-center justify-center rounded-full bg-muted-foreground/80 text-background hover:bg-destructive group-hover/tb:flex"
class="win-ctrl absolute -top-1.5 -right-1.5 hidden size-4 group-hover/tb:flex"
onclick={(e) => {
e.stopPropagation()
wm.close(win.id)
@@ -113,7 +113,7 @@
<div class="flex shrink-0 items-center gap-0.5">
<button
type="button"
class="flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
class="flex items-center justify-center border border-border p-1.5 text-muted-foreground transition-colors hover:border-foreground hover:bg-foreground hover:text-background"
onclick={() => toggleTheme()}
title="Cycle theme ({THEME_LABELS[getTheme()]})"
aria-label="Cycle theme, currently {THEME_LABELS[getTheme()]}"
@@ -122,12 +122,12 @@
</button>
<button
type="button"
class="flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
class="flex items-center justify-center border border-border p-1.5 text-muted-foreground transition-colors hover:border-foreground hover:bg-foreground hover:text-background"
onclick={() => openAppWindow('settings')}
title="Settings"
>
<SettingsIcon class="size-4" />
</button>
<span class="px-1.5 text-[11px] text-muted-foreground select-none">{VERSION}</span>
<span class="px-1.5 font-mono text-[11px] text-muted-foreground select-none">{VERSION}</span>
</div>
</div>

View File

@@ -67,18 +67,18 @@
<section use:dk.window={{ id }} class="min-w-0" aria-label={win.title}>
<header
data-wm-drag
class="flex shrink-0 cursor-move items-center justify-between gap-2 border-b bg-muted/40 px-3 py-1.5"
class="flex h-9 shrink-0 cursor-move items-center justify-between gap-2 overflow-hidden border-b border-border bg-background px-3"
>
<span
data-wm-title
class="flex min-w-0 flex-1 items-center self-stretch truncate font-mono text-xs font-medium"
class="min-w-0 flex-1 truncate font-mono text-xs font-medium"
>{win.title}</span
>
<div class="flex shrink-0 items-center gap-0.5">
<div class="flex shrink-0 items-center gap-1">
<button
type="button"
data-wm-minimize
class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
class="win-ctrl flex size-6"
aria-label="Minimize {win.title}"
>
<MinusIcon class="size-3.5" />
@@ -86,7 +86,7 @@
<button
type="button"
data-wm-maximize
class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
class="win-ctrl flex size-6"
aria-label="Maximize {win.title}"
>
<Maximize2Icon class="size-3.5" />
@@ -94,7 +94,7 @@
<button
type="button"
data-wm-close
class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
class="win-ctrl flex size-6"
aria-label="Close {win.title}"
>
<XIcon class="size-3.5" />

View File

@@ -188,7 +188,7 @@
<div class="flex flex-wrap gap-1.5">
{#each stats.topTags as [tag, count] (tag)}
<span
class="flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground"
class="flex items-center gap-1 border px-2 py-0.5 text-[11px] text-muted-foreground"
>
{tag}
<span class="text-foreground/70 tabular-nums">{count}</span>

View File

@@ -377,7 +377,7 @@
{#if item.tags.length}
<div class="mb-3 flex max-w-[68ch] flex-wrap gap-1.5">
{#each item.tags as t (t)}<span
class="rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground"
class="border px-2 py-0.5 text-[11px] text-muted-foreground"
>{t}</span
>{/each}
</div>