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
23 KiB
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
- 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. - Extend
web/src/lib/stores/theme.svelte.tsfrom a 2-state'light'|'dark'toggle to a named-theme model, keeping.darkclass behavior for compatibility. - Self-host JetBrains Mono (body) + a pixel/terminal face (VT323 or Departure
Mono) for the logo/headings accents, replacing the Google Fonts
<link>. - Build
web/src/lib/components/RasterImage.svelte: draws any image to a<canvas>reduced to a 2-color (themefg/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). - 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 externalentry.*.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-xslabels. .terminal-box: the universal card. Bordered (border border-border), square corners (rounded-noneeverywhere —--radiusis effectively 0), and on focus/emphasis getsring-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:
BrailleSpinnercomponent 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 solidin a border color derived fromfg/fgDimat low alpha (their--color-borderis not literally in the dump, but every bordered surface uses it, and it tracksfg).
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-autoand[&>img]:…size both responsively. - Emits a
.raster-image-skeletonplaceholder (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:
/* ── 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:
.darkvsdata-theme. The current store flips.darkon<html>. To keep Carbon working unchanged, leave.darklogic alone and layerdata-themeon top: when a cyberspace theme is active the store setsdata-themeand 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). Exposescale(0–1) to control.- Reads the active theme's
--foreground/--backgroundviagetComputedStyle(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
- Show
.raster-image-skeleton(empty,background: var(--background)) until the source image loads — matches cyberspace's no-flash placeholder. - On load: create an offscreen canvas at
width × (h/w*width),drawImage(withimageSmoothingEnabled = truefor the downscale), pullgetImageData. - Run Atkinson dithering to 2 colors:
- For each pixel: luminance
Y = 0.299R + 0.587G + 0.114B. - Threshold at 128 (+ optional
bias), snap to eitherfgorbg. - 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.
- For each pixel: luminance
putImageData. Canvas is the visible output; the loaded<img>is kept asaria-hiddenfallback for no-JS / copy-image / accessibility.- Re-dither on theme change: subscribe to the theme store; when it flips,
re-read
--foreground/--backgroundand re-run steps 3–4 (cheap — the decodedImageBitmapis cached, only the palette pass reruns). This is the detail that makes the art flip polarity with the theme toggle. - Respect
prefers-reduced-data/ reduced motion? Dithering is not motion, but offer aplainprop 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.sveltealready 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
plainprop — 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.woff2andVT323-Regular.woff2underweb/static/fonts/(orweb/public/fonts/— match where static assets are served from; checkvite.config). - Add
@font-faceblocks at the top ofapp.csswithfont-display: swap. - For the cyberspace themes only, the
--font-sans/--font-mono/--font-headingoverrides 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. applyClassbecomesapplyTheme: setsdata-themeon<html>and toggles.darkonly forcarbon(so Terracotta and both cyberspace themes run with no.dark). This is important: the.darkblock inapp.cssmust not layer on top of the cyberspace token blocks — cyberspace sets its own polarities.- Update
THEME_LABELSto the four names; update whatever UI surfaces the picker (search forTHEME_LABELS/toggleThemeusages — likelySettings.svelteor 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 thering-2 ring-fgfocus. Lets cards opt into the terminal look without a component rewrite..braille-spinner— keyframe cycling⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏as::aftercontent, coloredvar(--muted-foreground). Alternative toSpinner.sveltefor 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)
- Fonts (§5) — self-host JetBrains Mono + VT323,
@font-facein app.css. No visual change yet (only cyberspace themes reference them). - Tokens (§3) — add the two
:root[data-theme='cyber-*']blocks. - Store (§6) — extend
theme.svelte.tsto 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. RasterImage.svelte(§4) — build + wire into entity icons andConfigBackground. This is the "dithered image" deliverable.- 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 inweb/package.json) — Tailwind v4 must accept the newdata-themeselectors andcolor-mix()(both standard; no config change expected).npm run check/svelte-checkfor the store + component TS.- Manual: cycle all four themes in the picker; confirm no
.darkbleed oncyber-light; confirmRasterImagere-dithers on theme flip; confirmprefers-reduced-data/plainprop shows crisp image; confirm cross-originsrcdegrades 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-darkthe 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
.darkcoupling. Existing code may equate.darkwith "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.