Files
oikos/web/src/lib/components/RasterImage.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

207 lines
5.7 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>