the wallpaper, the header and the mark belong to the theme

Asked whether these could follow the theme too. They are the three most visible things
on screen and were the three least reachable: `Background.tsx` hardcoded
`url('/landscape1.webp')`, the header carried `rgba(255,255,255,0.25)` and `#1d2724`
in an inline style, and the logo was an `<img src>`.

Between them that is most of what "the app looks like Officer" actually meant. The
Plain theme turned every panel white and all three stayed exactly as they were, which
is how they were found — the leak detector doing its job on its first outing.

Verified across all three themes:

  duck-dark   wallpaper=landscape1.webp  header=rgba(255,255,255,0.25)
  duck-light  wallpaper=landscape1.webp  header=rgba(255,255,255,0.25)
  plain       wallpaper=none             header=rgb(255,255,255)

── Images could not be done the obvious way ──

`--logo-image: url('/officer-logo.svg')` fails the build. Bun's CSS bundler resolves
`url()` at BUILD time and these files are served from `public/` at RUNTIME, so it goes
looking for a module that does not exist. Colours apply themselves; images cannot.

So a theme publishes a quoted PATH — an ordinary string token the bundler ignores —
and `useThemeAsset` reads it and wraps it in `url()`. One hook rather than the read
inline in two components, because the quote-stripping is the kind of detail that gets
forgotten the second time.

The logo is a `<div role="img">` with a background image rather than an `<img>`, since
CSS cannot set a `src` and a white-labelled build has to replace the mark from its
theme rather than by editing the header.

The header's title colour goes through a `.header-ink` class: `EditableTitle` accepts
a className and not a style, and the token stays the source of truth either way.

Panel radius and the ~985 raw palette usages are still outstanding — see
THEMES-NOTES.md.

tsgo clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 22:52:43 +00:00
co-authored by Claude Opus 5
parent 1be4bcd39a
commit e9cb244818
9 changed files with 129 additions and 12 deletions
+10
View File
@@ -73,3 +73,13 @@
.file-grid [data-file-item][data-state='open'] .file-item-ellipsis {
opacity: 1 !important;
}
/* The header's own ink. A class rather than a variable at the call site, because `EditableTitle` accepts a
className and not a style — the token is still the source of truth. */
.header-ink {
color: var(--header-fg);
}
.header-ink-border {
border-color: color-mix(in srgb, var(--header-fg) 40%, transparent);
}
@@ -47,6 +47,22 @@
--page-bg: hsl(222.2 84% 4.9%);
--pixel-grid-color: rgba(200, 230, 210, 0.08);
/* ── The wallpaper, the header and the mark ───────────────────────────────────────────────────
All three were drawn by components: `Background.tsx` hardcoded `/landscape1.webp`, the header
carried `rgba(255,255,255,0.25)` in an inline style, and the logo was an `<img src>`. None of them
could be changed by a theme, which is most of what "the app looks like Officer" actually means.
Complete CSS values, so a theme can use an image, a gradient, or nothing at all. */
/* A quoted PATH, not a `url()`. Bun's CSS bundler resolves `url()` at build time and this file is
served from `public/` at runtime, so a url() token fails the build outright. `Background.tsx` wraps
it. A theme wanting a gradient instead sets `--page-bg` above and leaves this empty. */
--page-bg-src: '/landscape1.webp';
--header-bg: rgba(255, 255, 255, 0.25);
--header-border: rgba(255, 255, 255, 0.35);
--header-fg: #1d2724;
--header-blur: 24px;
--logo-src: '/officer-logo.svg';
/* ── Surfaces ─────────────────────────────────────────────────────────────────────────────────── */
--glass-bg: rgba(0, 0, 0, 0.2);
--glass-border: rgba(255, 255, 255, 0.1);
@@ -49,6 +49,22 @@
--page-bg: hsl(0 0% 100%);
--pixel-grid-color: rgba(20, 83, 45, 0.1);
/* ── The wallpaper, the header and the mark ───────────────────────────────────────────────────
All three were drawn by components: `Background.tsx` hardcoded `/landscape1.webp`, the header
carried `rgba(255,255,255,0.25)` in an inline style, and the logo was an `<img src>`. None of them
could be changed by a theme, which is most of what "the app looks like Officer" actually means.
Complete CSS values, so a theme can use an image, a gradient, or nothing at all. */
/* A quoted PATH, not a `url()`. Bun's CSS bundler resolves `url()` at build time and this file is
served from `public/` at runtime, so a url() token fails the build outright. `Background.tsx` wraps
it. A theme wanting a gradient instead sets `--page-bg` above and leaves this empty. */
--page-bg-src: '/landscape1.webp';
--header-bg: rgba(255, 255, 255, 0.25);
--header-border: rgba(255, 255, 255, 0.35);
--header-fg: #1d2724;
--header-blur: 24px;
--logo-src: '/officer-logo.svg';
/* ── Surfaces ─────────────────────────────────────────────────────────────────────────────────
Panels are the cornerstone of the layout, so they are themed as first-class surfaces rather than
inheriting whatever `card` happens to be. These were `bg-black/10` and `duck-teal` literals written
+11
View File
@@ -52,6 +52,17 @@
--page-bg: #ffffff;
--pixel-grid-color: transparent;
/* ── The wallpaper, the header and the mark ───────────────────────────────────────────────────
No wallpaper, no blur, a hard border. If an image appears behind the panels under this theme,
something is still drawing it from outside the theme layer. */
/* Empty means no wallpaper — see the note in the Duck themes. */
--page-bg-src: '';
--header-bg: #ffffff;
--header-border: #000000;
--header-fg: #000000;
--header-blur: 0px;
--logo-src: '/officer-logo.svg';
/* ── Surfaces ─────────────────────────────────────────────────────────────────────────────────
Opaque white with a hard black border, and square corners. The app's panels are translucent with
rounded corners and a grid fill, so any panel that still looks like that under Plain is styling itself
+1
View File
@@ -5,5 +5,6 @@
* machinery — the app's stylesheet imports one, its React tree imports the other.
*/
export { ThemeProvider, useTheme, useColorMode } from './ThemeProvider';
export { useThemeAsset } from './useThemeAsset';
export { THEMES, DEFAULT_THEME, findTheme, isKnownTheme, isDarkTheme, themeFromLegacyColorMode } from './themes';
export type { Theme, ThemeId } from './themes';
+32
View File
@@ -0,0 +1,32 @@
import { useEffect, useState } from 'react';
import { useTheme } from './ThemeProvider';
/**
* An asset PATH published by the active theme — the wallpaper, the logo.
*
* ── Why assets are strings and colours are not ──
*
* Everything else a theme owns is applied by the browser: set `--panel-bg` and panels change, with no code
* involved. Images cannot work that way here. The natural form, `--logo-image: url('/officer-logo.svg')`,
* fails the build outright — Bun's CSS bundler resolves `url()` at build time, and these files are served
* from `public/` at runtime, so it goes looking for a module that does not exist.
*
* So a theme publishes a quoted PATH, which is an ordinary string token the bundler ignores, and the
* component wraps it in `url()`. This hook is that read, in one place, because doing it inline in two
* components was two chances to forget the quote-stripping.
*
* Re-reads when the theme changes: a custom property is not something React can subscribe to, and the
* theme id changing is the only moment its value can have moved.
*/
export function useThemeAsset(property: string): string {
const { themeId } = useTheme();
const [value, setValue] = useState('');
useEffect(() => {
const raw = getComputedStyle(document.documentElement).getPropertyValue(property).trim();
// CSS strings arrive quoted. An empty result means the theme deliberately has no such asset.
setValue(raw.replace(/^['"]|['"]$/g, ''));
}, [property, themeId]);
return value;
}