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

504 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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.
---
## 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` (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):
```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.