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:
@@ -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>
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
*
|
||||
* New code should import from `theme` directly. This file exists to be deleted once nothing points here.
|
||||
*/
|
||||
export { ThemeProvider as ColorModeProvider, ThemeProvider, useTheme, useColorMode } from 'theme';
|
||||
export { ThemeProvider as ColorModeProvider, ThemeProvider, useTheme, useColorMode, useThemeAsset } from 'theme';
|
||||
export type { Theme, ThemeId } from 'theme';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user