# 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 `` 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 **``** component that renders images to a `` 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 ``. 4. Build `web/src/lib/components/RasterImage.svelte`: draws any image to a `` reduced to a 2-color (theme `fg`/`bg`) palette via **Atkinson dithering**, with an `` 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, `` 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:** `Ads Videos …` — crossed-out `` 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 **``** as primary output, with an **`` 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 `` (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 ``. 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, 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 ``` - `src`, `alt` — as ``. - `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 `` 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 `` 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 `` (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 `` 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 `` 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 `` 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 `` without console errors. - Lighthouse / a11y: 1-bit dithered images still need a real `alt` (kept on the fallback ``); 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 `` (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.