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
@@ -1,12 +1,29 @@
import { PixelGrid } from '@/components/PixelGrid';
const landscapebg = '/landscape1.webp';
import { useThemeAsset } from '@/components/ui/ThemeProvider';
/**
* The page behind everything, owned by the theme rather than by this file.
*
* It used to hardcode `url('/landscape1.webp')`. That one line was most of what "the app looks like
* Officer" meant, and no theme could reach it — under the Plain theme every panel went white and the
* photograph stayed, which is how it was found.
*
* ── Why the path is read from CSS rather than written in CSS ──
*
* The natural form is `--page-bg-image: url('/landscape1.webp')`, and it fails the build: Bun's CSS
* bundler resolves `url()` at build time, while this file is served from `public/` at runtime. So the theme
* publishes a quoted PATH — a string token the bundler ignores — and this wraps it.
*
* Re-read when the theme changes, because a custom property is not something React can subscribe to.
*/
export function Background() {
const src = useThemeAsset('--page-bg-src');
return (
<div
className="absolute inset-0 z-0"
style={{
backgroundImage: `url(${landscapebg})`,
backgroundImage: src ? `url(${src})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center center',
}}
@@ -10,6 +10,7 @@ import { UserMenu } from './UserMenu';
import { JobsIndicator } from './JobsIndicator';
import { RescanButton } from '../Rescan/RescanButton';
import { usePageTitle } from '@/state/usePageTitle';
import { useThemeAsset } from '@/components/ui/ThemeProvider';
// import { BugReportButton } from '../BugReport/BugReportButton';
// Center title: click to rename this browser tab. The name is kept in sessionStorage, so it survives a
@@ -25,8 +26,8 @@ function EditablePageTitle() {
allowEmpty
ariaLabel="Tab name"
hint="Click to rename this tab — clear it to go back to the page name"
className="pointer-events-auto max-w-[50vw] cursor-text truncate text-base font-bold text-[#1d2724] transition-opacity hover:opacity-70 md:text-xl"
inputClassName="pointer-events-auto max-w-[50vw] border-b border-[#1d2724]/40 bg-transparent text-center text-base font-bold text-[#1d2724] outline-none md:text-xl"
className="header-ink pointer-events-auto max-w-[50vw] cursor-text truncate text-base font-bold transition-opacity hover:opacity-70 md:text-xl"
inputClassName="header-ink header-ink-border pointer-events-auto max-w-[50vw] border-b bg-transparent text-center text-base font-bold outline-none md:text-xl"
/>
);
}
@@ -44,12 +45,20 @@ type HeaderProps = {
export function Header({ dockItems, hidden }: HeaderProps) {
const [open, setOpen] = useState(false);
const isTouch = useIsTouch();
// The mark comes from the theme — a white-labelled build replaces it without touching this file.
const logo = useThemeAsset('--logo-src');
return (
<header className={`fixed z-10 w-full ${hidden ? 'hidden' : ''}`}>
<div
className="relative shrink-0 border-b backdrop-blur-xl shadow-lg px-3 py-2 md:px-6 md:py-3 flex items-center justify-between"
style={{ backgroundColor: 'rgba(255, 255, 255, 0.25)', borderColor: 'rgba(255, 255, 255, 0.35)' }}
className="relative shrink-0 border-b shadow-lg px-3 py-2 md:px-6 md:py-3 flex items-center justify-between"
// Themed. These were literal rgba values, so the header stayed Officer-white under every theme —
// the most visible thing on screen and the least reachable.
style={{
backgroundColor: 'var(--header-bg)',
borderColor: 'var(--header-border)',
backdropFilter: 'blur(var(--header-blur))',
}}
>
{/* Centered, editable page title */}
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
@@ -62,11 +71,16 @@ export function Header({ dockItems, hidden }: HeaderProps) {
</button>
)}
<Link to="/">
<img
src="/officer-logo.svg"
alt="Officer"
className="h-8 md:h-12 w-auto"
style={{ transform: 'skew(-15deg, -2deg)' }}
{/*
A background-image, not an `<img src>`: CSS cannot set a `src`, and a white-labelled build
must be able to replace the mark from its theme rather than by editing this file. `role`
and `aria-label` keep it announced now that it is not an image element.
*/}
<div
role="img"
aria-label="Officer"
className="h-8 md:h-12 w-24 md:w-36 bg-contain bg-left bg-no-repeat"
style={{ backgroundImage: logo ? `url(${logo})` : 'none', transform: 'skew(-15deg, -2deg)' }}
/>
</Link>
</div>