# 2026-08-03 — Adopt cyberspace.online terminal aesthetic + dithered images

**Status:** Implemented in v0.16.0 (`757ef2f`). Shipped as a **full theme
replacement** (Terracotta/Carbon → cyberspace BBS/terminal style), not the
opt-in addition originally drafted below — the operator chose full replacement
during execution (see decision `theme.replace_with_cyberspace`). The `<RasterImage>`
Atkinson-dithering component and the warm-cream/JetBrains-Mono look landed as
drafted; only the "opt-in vs replace" scope changed.

Adopt the look of https://cyberspace.online/ (a BBS / "social media
de-imagined" terminal aesthetic) as a **new, opt-in theme family** in oikos,
with **both light and dark variants**, plus a reusable **`<RasterImage>`**
component that renders images to a `<canvas>` with Atkinson dithering (the
"kinda dithered" image style). The existing Terracotta/Carbon themes stay the
default; this adds, it does not replace.

---

## TL;DR

1. Add a third theme family — **"Cyberspace Dark"** and **"Cyberspace Light"** —
   wired through the same `--background` / `--foreground` / … token layer every
   component already uses, so nothing in the UI tree changes; only the tokens
   get new values. Square corners (`--radius: 0`), warm cream-on-black, mono
   everything.
2. Extend `web/src/lib/stores/theme.svelte.ts` from a 2-state `'light'|'dark'`
   toggle to a named-theme model, keeping `.dark` class behavior for
   compatibility.
3. Self-host JetBrains Mono (body) + a pixel/terminal face (VT323 or Departure
   Mono) for the logo/headings accents, replacing the Google Fonts `<link>`.
4. Build `web/src/lib/components/RasterImage.svelte`: draws any image to a
   `<canvas>` reduced to a 2-color (theme `fg`/`bg`) palette via **Atkinson
   dithering**, with an `<img>` fallback and a skeleton placeholder — exactly
   the cyberspace pattern. Re-renders when the theme changes (palette flips).
5. Optional cosmetic idioms (terminal-box focus ring, braille spinner, `<s>`
   strike lists) as small additive utilities, not a redesign.

The whole thing is **non-breaking and incremental**: each step ships behind the
existing theme picker, so Terracotta/Carbon users see nothing until they opt in.

---

## 1. Extracted style spec (source of truth from cyberspace.online)

Captured from the live site's SSR HTML + inline boot script. This is the
reference the tokens below are derived from.

### 1.1 Color model

Cyberspace defines **exactly three colors per theme** — `fg`, `bg`, `fgDim` —
applied to CSS custom properties. Everything else (borders, primary, cards) is
*derived* from those three. There are 11 named themes total; the two we care
about:

| Theme   | `fg` (text)  | `bg` (canvas) | `fgDim` (muted) |
|---------|--------------|---------------|-----------------|
| Dark    | `#efe5c0`    | `#000000`     | `#a89984`       |
| Light   | `#000000`    | `#efe5c0`     | `#3a3a3a`       |

Note the elegance: **light and dark are exact inverses** — they share the same
warm cream (`#efe5c0`, a Gruvbox-ish paper tone) and just swap which side of it
is ink vs. paper. The muted tone `#a89984` is straight out of the Gruvbox
palette. This is why both themes read as "the same site" despite opposite
polarity.

Boot-time fallback (the site's original/GRiD theme) is amber `#FF9810` on
`#120900` — useful as a *third* optional accent if we ever want a true-phosphor
variant.

### 1.2 Type

- **Body / mono:** JetBrains Mono (self-hosted `.woff2`, Regular).
- **Boot + logo accents:** Departure Mono (self-hosted `.woff2`). A quirky
  monospace; VT323 (Google, free) is a close, easy substitute.
- **Stylized wordmark** (`ᑕ¥βєяรקค¢є`, class `.font-vt`): a terminal/pixel face.
  Rule lives in their external `entry.*.css` (not in the SSR dump); VT323 is the
  safe assumption.

cyberspace sets `font-mono` on the root wrapper — the **entire UI is
monospace**. There is no proportional body face. Headings use the same mono
family at larger size / normal weight.

### 1.3 Layout & component idioms

- **Left rail nav:** fixed, icon-only when minimized (~80px), expands on click.
  Square buttons, Phosphor icons, uppercase `text-xs` labels.
- **`.terminal-box`:** the universal card. Bordered (`border border-border`),
  **square corners** (`rounded-none` everywhere — `--radius` is effectively 0),
  and on focus/emphasis gets `ring-2 ring-fg` (a 2px ring in the foreground
  color).
- **Emphasis by inversion:** active/primary state is `bg-fg text-bg` — fill with
  foreground ink, text becomes the canvas color. No separate "accent" hue; the
  accent *is* fg.
- **Strikethrough as a feature list:** `<s>Ads</s> <s>Videos</s> …` — crossed-out
  `<s>` elements spell out what the product removes. Cheap, on-brand.
- **Braille spinner:** `BrailleSpinner` component animates braille block chars
  (`⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏`) for loading states instead of a circle.
- **Max content width** `max-w-4xl`, centered; generous vertical rhythm; thin
  2px scrollbars colored `--color-border`.
- Borders are `1px solid` in a border color derived from `fg`/`fgDim` at low
  alpha (their `--color-border` is not literally in the dump, but every
  bordered surface uses it, and it tracks `fg`).

### 1.4 The dithered image (`RasterImage`) — what we actually know

From the SSR HTML the component is unambiguous about its *shape*, silent on its
*algorithm* (the dither JS is in an external `/_nuxt/*.js` bundle not present in
the page dump):

- Renders a **`<canvas>`** as primary output, with an **`<img>` fallback** as a
  sibling. Parent selectors `[&>canvas]:max-w-full [&>canvas]:h-auto` and
  `[&>img]:…` size both responsively.
- Emits a **`.raster-image-skeleton`** placeholder (empty div, `background:
  var(--color-bg)`) during SSR/before hydration — no flash of the raw photo.
- Scoped styles (`data-v-4d61df89`): `.raster-image { display:block }`,
  `.raster-image-skeleton { display:block; background:var(--color-bg) }`.

**Inferred technique** (standard for this look): Canvas 2D → `drawImage` →
`getImageData` → per-pixel luminance reduction to a 2-color palette (`fg`/`bg`)
with an **error-diffusion** pass (Atkinson or Floyd–Steinberg) → `putImageData`.
This produces the characteristic speckled 1-bit halftone. Target is almost
certainly the theme's own fg/bg, which is *why* the dithered art recolors
correctly when you flip themes.

We will implement Atkinson (see §4) — it's the classic Mac/BBS dither, slightly
softer than Floyd–Steinberg, and matches "kinda dithered" precisely.

---

## 2. Recommended approach: opt-in theme family (not a rebrand)

oikos today = Art-Nouveau / terracotta / rounded / serif-heading (Inknut
Antiqua), floating-window desktop shell. cyberspace = BBS / mono / square /
cream-on-black. These are **opposite poles**; a flat rebrand would discard the
existing art direction and rework every component's rounding/spacing.

**Decision: add cyberspace as a new theme family, selectable in the existing
theme picker.** This is low-risk, reversible, and lets the dithered images +
terminal idioms land incrementally. The full-rebrand alternative is documented
in §7 for if you later decide to make it the default.

Because every oikos component consumes colors through the Tailwind v4 token
layer (`--background`, `--foreground`, `--card`, `--border`, `--primary`, …)
defined in `web/src/app.css` `@theme inline`, a new theme is **just a new set
of values for those same custom properties** — zero component edits required
for the recolor. That indirection is the whole reason this is cheap.

---

## 3. Theme token additions (`web/src/app.css`)

Add two new blocks alongside the existing `:root` (Terracotta) and `.dark`
(Carbon). They set the *same* token names to cyberspace's values, plus pin
`--radius: 0` for square corners and remap fonts (see §5).

Driven by a `data-theme` attribute on `<html>` (set by the store, §6), so all
four states — Terracotta, Carbon, Cyberspace Dark, Cyberspace Light — coexist:

```css
/* ── Cyberspace Dark (cream on black) ── */
:root[data-theme='cyber-dark'] {
  --radius: 0px;
  --background: #000000;
  --foreground: #efe5c0;
  --card: #000000;                 /* cyberspace has no card tint; cards are just bordered bg */
  --card-foreground: #efe5c0;
  --popover: #000000;
  --popover-foreground: #efe5c0;
  --primary: #efe5c0;              /* emphasis = fg ink */
  --primary-foreground: #000000;   /* inverted */
  --secondary: #1a1a1a;
  --secondary-foreground: #efe5c0;
  --muted: #141414;
  --muted-foreground: #a89984;     /* fgDim */
  --accent: #efe5c0;
  --accent-foreground: #000000;
  --destructive: #cc241d;          /* Gruvbox red, sits in the same palette */
  --destructive-foreground: #efe5c0;
  --border: color-mix(in oklab, #efe5c0 22%, transparent); /* fg-derived hairline */
  --input: color-mix(in oklab, #efe5c0 28%, transparent);
  --ring: #efe5c0;                 /* the ring-2 ring-fg look */
  --sidebar: #000000;
  --sidebar-foreground: #efe5c0;
  --sidebar-primary: #efe5c0;
  --sidebar-primary-foreground: #000000;
  --sidebar-accent: #1a1a1a;
  --sidebar-accent-foreground: #efe5c0;
  --sidebar-border: color-mix(in oklab, #efe5c0 22%, transparent);
  --sidebar-ring: #efe5c0;
  --chart-1: #efe5c0; --chart-2: #a89984; --chart-3: #fabd2f;
  --chart-4: #b8bb26; --chart-5: #83a598;   /* Gruvbox for charts */
  --success: #b8bb26; --warning: #fabd2f;

  /* oikos semantic aliases (app.css :root block) */
  --bg: var(--background); --bg-surface: var(--card); --bg-deeper: #050505;
  --bg-hover: var(--secondary); --bg-active: var(--accent);
  --text: var(--foreground); --text-muted: var(--muted-foreground);
  --accent-blue: #83a598; --accent-green: var(--success);
  --accent-red: var(--destructive); --accent-orange: var(--warning);

  /* terminal face for this theme only (see §5) */
  --font-sans: 'JetBrains Mono', ui-monospace, Menlo, monospace;
  --font-mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
  --font-heading: 'VT323', 'JetBrains Mono', monospace; /* pixel wordmark feel */
}

/* ── Cyberspace Light (black on cream paper) — exact inverse ── */
:root[data-theme='cyber-light'] {
  --radius: 0px;
  --background: #efe5c0;
  --foreground: #000000;
  --card: #efe5c0;
  --card-foreground: #000000;
  --popover: #efe5c0;
  --popover-foreground: #000000;
  --primary: #000000;
  --primary-foreground: #efe5c0;
  --secondary: #e0d6b0;
  --secondary-foreground: #000000;
  --muted: #e6dcc0;
  --muted-foreground: #3a3a3a;     /* fgDim */
  --accent: #000000;
  --accent-foreground: #efe5c0;
  --destructive: #9d0006;
  --destructive-foreground: #efe5c0;
  --border: color-mix(in oklab, #000000 22%, transparent);
  --input: color-mix(in oklab, #000000 28%, transparent);
  --ring: #000000;
  --sidebar: #efe5c0;
  --sidebar-foreground: #000000;
  --sidebar-primary: #000000;
  --sidebar-primary-foreground: #efe5c0;
  --sidebar-accent: #e0d6b0;
  --sidebar-accent-foreground: #000000;
  --sidebar-border: color-mix(in oklab, #000000 22%, transparent);
  --sidebar-ring: #000000;
  --chart-1: #000000; --chart-2: #3a3a3a; --chart-3: #b57614;
  --chart-4: #79740e; --chart-5: #076678;
  --success: #79740e; --warning: #b57614;

  --bg: var(--background); --bg-surface: var(--card); --bg-deeper: #e6dcc0;
  --bg-hover: var(--secondary); --bg-active: var(--accent);
  --text: var(--foreground); --text-muted: var(--muted-foreground);
  --accent-blue: #076678; --accent-green: var(--success);
  --accent-red: var(--destructive); --accent-orange: var(--warning);

  --font-sans: 'JetBrains Mono', ui-monospace, Menlo, monospace;
  --font-mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
  --font-heading: 'VT323', 'JetBrains Mono', monospace;
}
```

Two notes:

- **`.dark` vs `data-theme`.** The current store flips `.dark` on `<html>`. To
  keep Carbon working unchanged, leave `.dark` logic alone and layer
  `data-theme` on top: when a cyberspace theme is active the store sets
  `data-theme` and **removes** `.dark` (cyberspace themes are self-contained —
  they set both polarities explicitly). See §6.
- **Borders from `fg`.** cyberspace's hairline tracks the foreground, not a
  fixed gray. `color-mix(in oklab, <fg> 22%, transparent)` reproduces that and
  auto-flips between the two themes. Tune the % after visual review.

---

## 4. The dithered image component (`RasterImage.svelte`)

**File:** `web/src/lib/components/RasterImage.svelte` (sibling of the existing
`Spinner.svelte`).

### 4.1 API

```svelte
<RasterImage src={entity.iconUrl} alt="host icon" width={320} />
<!-- optional: scale (downsample factor), threshold bias, mono palette override -->
```

- `src`, `alt` — as `<img>`.
- `width` — render width in CSS px; canvas is sized to this × natural aspect.
  Downscaling before dithering is what sells the "lo-fi" look (defaults ~256–
  320). Expose `scale` (0–1) to control.
- Reads the active theme's `--foreground` / `--background` via
  `getComputedStyle(document.documentElement)` so the dither palette **follows
  the theme** (cream/black in cyber-dark, black/cream in cyber-light, and
  perfectly sensible in Terracotta/Carbon too).

### 4.2 Behavior

1. Show `.raster-image-skeleton` (empty, `background: var(--background)`) until
   the source image loads — matches cyberspace's no-flash placeholder.
2. On load: create an offscreen canvas at `width × (h/w*width)`, `drawImage`
   (with `imageSmoothingEnabled = true` for the downscale), pull
   `getImageData`.
3. Run **Atkinson dithering** to 2 colors:
   - For each pixel: luminance `Y = 0.299R + 0.587G + 0.114B`.
   - Threshold at 128 (+ optional `bias`), snap to either `fg` or `bg`.
   - Push **1/8 of the quantization error** to each of 6 neighbors (Atkinson's
     kernel): right, below-left, below, below-right, and two pixels down on the
     next-next row. (Atkinson diffuses less than Floyd–Steinberg → softer, more
     "screen-printed" — exactly the cyberspace feel.)
   - Write `fg`/`bg` (read from CSS vars at render time) into the buffer.
4. `putImageData`. Canvas is the visible output; the loaded `<img>` is kept as
   `aria-hidden` fallback for no-JS / copy-image / accessibility.
5. **Re-dither on theme change**: subscribe to the theme store; when it flips,
   re-read `--foreground`/`--background` and re-run steps 3–4 (cheap — the
   decoded `ImageBitmap` is cached, only the palette pass reruns). This is the
   detail that makes the art flip polarity with the theme toggle.
6. **Respect `prefers-reduced-data` / reduced motion?** Dithering is not motion,
   but offer a `plain` prop to skip the canvas and render the raw `<img>` for
   users who want crisp photos (e.g. entity detail screens where legibility
   beats aesthetic).

### 4.3 Reference dither kernel (Atkinson)

```
        *   →   1/8   1/8
1/8 1/8 1/8       (current pixel = *)
    1/8 1/8        (* is at top-left of this 4×? — see standard Atkinson spread)
```

Spread pattern (error e from pixel at (x,y) distributed):

```
              px        x+1 (1/8)   x+2 (1/8)
x-1 (1/8)   x (1/8)   x+1 (1/8)
              x+1 (1/8)   x+2 (1/8)   [next row offsets]
```

Concretely, 6 neighbors each get `e/8`: `(x+1,y)`, `(x+2,y)`, `(x-1,y+1)`,
`(x,y+1)`, `(x+1,y+1)`, `(x,y+2)`. (Clamp at edges — drop, don't wrap.)

### 4.4 Where to use it

- Entity icons / host thumbnails in the KB and entity desktop (the obvious win).
- Mascot or login/Config background art (`ConfigBackground.svelte` already
  exists — a dithered backdrop there would be striking).
- Any user-uploaded image in chat/knowledge where we want the "de-imagined"
  tone. Keep it **opt-in per call site** via the `plain` prop — don't dither
  diagrams/screenshots that need to stay readable.

### 4.5 Cross-origin caveat

`getImageData` throws on tainted canvases. If `src` is cross-origin and the
server doesn't send CORS headers, fall back to the plain `<img>` (log once).
For self-hosted assets (the common case here) it's a non-issue.

---

## 5. Fonts: self-host JetBrains Mono + VT323

cyberspace self-hosts both faces as `.woff2`. oikos currently pulls DM Sans /
DM Mono / Inknut Antiqua from Google Fonts via a `<link>` in
`web/index.html:10`.

- Drop `JetBrainsMono-Regular.woff2` and `VT323-Regular.woff2` under
  `web/static/fonts/` (or `web/public/fonts/` — match where static assets are
  served from; check `vite.config`).
- Add `@font-face` blocks at the top of `app.css` with `font-display: swap`.
- For the cyberspace themes only, the `--font-sans`/`--font-mono`/`--font-heading`
  overrides in §3 remap the families — Terracotta/Carbon keep DM Sans/Inknut
  untouched. This is the key trick: **font choice is part of the theme**, not a
  global swap, so the two art directions don't fight.
- Leave the Google Fonts `<link>` in place for now (Terracotta/Carbon still need
  it); add a follow-up to self-host those too if we want to kill the external
  request entirely. Out of scope for this plan.

VT323 vs Departure Mono: VT323 is free on Google Fonts and trivial to self-host;
Departure Mono is the authentic cyberspace face but needs a license check.
**Recommend VT323** to start; swap to Departure Mono later if you want exact
fidelity.

---

## 6. Theme store changes (`web/src/lib/stores/theme.svelte.ts`)

Current: `Theme = 'light' | 'dark'`, flips `.dark` class. Extend to a named set
while preserving the existing API (callers of `toggleTheme`/`getTheme` keep
working):

```ts
export type ThemeName = 'terracotta' | 'carbon' | 'cyber-dark' | 'cyber-light'
// Back-compat aliases used by existing callers:
//   'light' -> 'terracotta', 'dark' -> 'carbon'
```

- Store key stays `oikos-theme`; migrate old `'light'`/`'dark'` values on read.
- `applyClass` becomes `applyTheme`: sets `data-theme` on `<html>` and toggles
  `.dark` **only** for `carbon` (so Terracotta and both cyberspace themes run
  with no `.dark`). This is important: the `.dark` block in `app.css` must not
  layer on top of the cyberspace token blocks — cyberspace sets its own
  polarities.
- Update `THEME_LABELS` to the four names; update whatever UI surfaces the
  picker (search for `THEME_LABELS` / `toggleTheme` usages — likely
  `Settings.svelte` or the desktop shell's chrome) to a 4-option control instead
  of a binary toggle.

**Watch out:** any code that assumes `document.documentElement.classList.contains('dark')`
≡ "dark colors" will be wrong for `cyber-dark`. Audit `grep -rn "classList.*dark\|\.dark" web/src` and prefer reading `getTheme()`/`data-theme` instead.

---

## 7. Optional cosmetic idioms (additive utilities)

Small, theme-aware utilities in `app.css` — usable in any theme but idiomatic
for cyberspace:

- `.terminal-box` — `{ border:1px solid var(--border); border-radius:0 }` plus a
  `.terminal-box:focus-within { box-shadow: 0 0 0 2px var(--ring) }` to mirror
  the `ring-2 ring-fg` focus. Lets cards opt into the terminal look without a
  component rewrite.
- `.braille-spinner` — keyframe cycling `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏` as `::after` content, colored
  `var(--muted-foreground)`. Alternative to `Spinner.svelte` for loading states
  under cyberspace themes.
- `.font-vt` — `{ font-family: var(--font-heading) }` so the wordmark class
  cyberspace uses maps to our heading var (VT323 under cyber themes, Inknut
  under Terracotta). Drop-in for any stylized title.
- `.strike-list` — `li > s { color: var(--muted-foreground) }` convenience for
  the crossed-out feature-list pattern in marketing/empty states.

None of these are required for the theme to work; they're palette for the
"de-imagined" voice where we want it.

---

## 8. Implementation order (incremental, each step shippable)

1. **Fonts** (§5) — self-host JetBrains Mono + VT323, `@font-face` in app.css.
   No visual change yet (only cyberspace themes reference them).
2. **Tokens** (§3) — add the two `:root[data-theme='cyber-*']` blocks.
3. **Store** (§6) — extend `theme.svelte.ts` to named themes + `data-theme`;
   update the picker UI. **At this point both cyberspace themes are live and
   fully recolor the whole app** — the cheapest milestone, biggest visible win.
4. **`RasterImage.svelte`** (§4) — build + wire into entity icons and
   `ConfigBackground`. This is the "dithered image" deliverable.
5. **Idioms** (§7) — terminal-box, braille spinner, etc., applied opportunistically.

Each step is independently mergeable. Step 3 alone satisfies "light + dark
cyberspace themes"; step 4 satisfies "dithered images."

---

## 9. Verification

- `cd web && npm run build` (or the repo's build command — confirm in
  `web/package.json`) — Tailwind v4 must accept the new `data-theme` selectors
  and `color-mix()` (both standard; no config change expected).
- `npm run check` / `svelte-check` for the store + component TS.
- Manual: cycle all four themes in the picker; confirm no `.dark` bleed on
  `cyber-light`; confirm `RasterImage` re-dithers on theme flip; confirm
  `prefers-reduced-data`/`plain` prop shows crisp image; confirm cross-origin
  `src` degrades to `<img>` without console errors.
- Lighthouse / a11y: 1-bit dithered images still need a real `alt` (kept on the
  fallback `<img>`); contrast on `#a89984`-on-black passes WCAG AA for body text
  (ratio ≈ 7.4:1) — fine.

---

## 10. Alternatives considered

- **Full rebrand (replace Terracotta/Carbon).** Highest visual payoff, highest
  cost: every component's rounding/serif/spacing was authored for the Art
ouveau
  direction; square + mono would need a component-level sweep, not just tokens.
  Defer unless you want cyberspace as *the* oikos look — then do it as a
  follow-up that deletes Terracotta/Carbon and makes `cyber-dark` the sole
  default.
- **CSS-only image dither (filters / SVG turbulence).** Cheaper, but can't do
  true 1-bit error diffusion or recolor to theme fg/bg. Rejected — the canvas
  pass is the whole point and is ~60 lines.
- **Ordered (Bayer) dither instead of Atkinson.** More regular/grid-like
  ("newspaper halftone"). Atkinson is softer and more terminal-like; keep
  Bayer as a `algorithm='bayer'` prop option later if wanted.
- **Server-side dithering.** Could pre-dither icons at ingest. Rejected for
  now — client canvas keeps one source of truth (the original image) and lets
  the palette follow the live theme, which a baked asset can't.

---

## 11. Non-goals / out of scope

- Replicating cyberspace's sidebar-rail *layout* (oikos uses a floating-window
  desktop shell; the rail is a different app model). We take the *visual*
  language, not the IA.
- Porting the 9 other novelty themes (C64, Matrix, VT320, …). Two (light/dark)
  satisfy the request; the token model makes adding more trivial later.
- Removing the Google Fonts dependency for Terracotta/Carbon (follow-up).
- Licensing/redistributing Departure Mono (use VT323 unless cleared).

---

## 12. Risks

- **`.dark` coupling.** Existing code may equate `.dark` with "dark UI".
  Mitigation: audit in step 3; the grep is small.
- **Dither perf on large images.** Atkinson is O(n) and runs on a downscaled
  canvas (≤~320px wide), so per-image cost is negligible; but batch-rendering
  many entity icons on first paint could jank. Mitigation: dither lazily (on
  intersection) and cache the result on the element.
- **Tainted canvas** on cross-origin images → silent fallback to `<img>`
  (already handled in the design).
- **Token drift.** If a component hardcodes a color instead of using a token,
  it won't recolor under cyberspace. This is the same risk Carbon already has;
  no new exposure, just more visible under a stronger theme.
