Files
oikos/plans/done/2026-08-03-cyberspace-style-adoption.md
dtoro 195d45a0e9
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
docs(plans): reconcile plan statuses; archive 10 done plans
Move ten completed plans from plans/ to plans/done/ and update the index:
- 2026-07-18 session-review-three-sessions, 2026-07-20 desktop-mascot,
  2026-07-20 session-review-ten-sessions, 2026-07-21 chat-full-polish,
  2026-07-29 health-check-reality-and-knowledge-graph,
  2026-07-30 session-review-plan-drift, and the four 2026-08-03 chat plans
  (changes-review, reliability-and-ux-audit, cyberspace-style-adoption,
  working-visibility).
- Refresh two stale statuses: cyberspace-style-adoption ("Draft" -> shipped as
  full replacement in v0.16.0/757ef2f) and health-check-reality ("ready for
  implementation" -> shipped across the v0.14.x-0.16.x check commits).
- .gitignore: ignore local tooling artifacts (.playwright-mcp/, config-screen.png).

No code change. index.md Active/Done tables now match the filesystem (no orphans).

VERSION: 0.17.0 -> 0.17.1
2026-08-03 22:52:25 +02:00

23 KiB
Raw Permalink Blame History

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 themefg, 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 → drawImagegetImageData → per-pixel luminance reduction to a 2-color palette (fg/bg) with an error-diffusion pass (Atkinson or FloydSteinberg) → 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 FloydSteinberg, and matches "kinda dithered" precisely.


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:

/* ── 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

<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 (01) 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 FloydSteinberg → 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 34 (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):

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-listli > 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.