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
207 lines
5.7 KiB
Svelte
207 lines
5.7 KiB
Svelte
<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 Floyd–Steinberg.
|
||
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>
|