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
504 lines
23 KiB
Markdown
504 lines
23 KiB
Markdown
# 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.
|