From e9cb24481857baa7ceb75734573fd9b5fd130fa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 16 Aug 2026 22:52:43 +0000 Subject: [PATCH] the wallpaper, the header and the mark belong to the theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ``. 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 `
` with a background image rather than an ``, 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 --- .../Screens/Dashboard/Layout/Background.tsx | 21 ++++++++++-- .../Dashboard/Layout/Header/Header.tsx | 32 +++++++++++++------ .../components/ui/ThemeProvider.tsx | 2 +- src/workspaces/theme/css/chrome.css | 10 ++++++ src/workspaces/theme/css/themes/duck-dark.css | 16 ++++++++++ .../theme/css/themes/duck-light.css | 16 ++++++++++ src/workspaces/theme/css/themes/plain.css | 11 +++++++ src/workspaces/theme/src/index.ts | 1 + src/workspaces/theme/src/useThemeAsset.ts | 32 +++++++++++++++++++ 9 files changed, 129 insertions(+), 12 deletions(-) create mode 100644 src/workspaces/theme/src/useThemeAsset.ts diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Background.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Background.tsx index c09c1895..92547ef8 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Background.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Background.tsx @@ -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 (
); } @@ -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 (
{/* Centered, editable page title */}
@@ -62,11 +71,16 @@ export function Header({ dockItems, hidden }: HeaderProps) { )} - Officer`: 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. + */} +
diff --git a/src/workspaces/components/ui/ThemeProvider.tsx b/src/workspaces/components/ui/ThemeProvider.tsx index 7a21d5cb..e61b5d13 100644 --- a/src/workspaces/components/ui/ThemeProvider.tsx +++ b/src/workspaces/components/ui/ThemeProvider.tsx @@ -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'; diff --git a/src/workspaces/theme/css/chrome.css b/src/workspaces/theme/css/chrome.css index 3e5e39a1..2948a0b8 100644 --- a/src/workspaces/theme/css/chrome.css +++ b/src/workspaces/theme/css/chrome.css @@ -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); +} diff --git a/src/workspaces/theme/css/themes/duck-dark.css b/src/workspaces/theme/css/themes/duck-dark.css index 9eb537f5..4a7792b0 100644 --- a/src/workspaces/theme/css/themes/duck-dark.css +++ b/src/workspaces/theme/css/themes/duck-dark.css @@ -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 ``. 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); diff --git a/src/workspaces/theme/css/themes/duck-light.css b/src/workspaces/theme/css/themes/duck-light.css index 9322f3f3..2c7ad1fe 100644 --- a/src/workspaces/theme/css/themes/duck-light.css +++ b/src/workspaces/theme/css/themes/duck-light.css @@ -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 ``. 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 diff --git a/src/workspaces/theme/css/themes/plain.css b/src/workspaces/theme/css/themes/plain.css index 43291928..fbfea95e 100644 --- a/src/workspaces/theme/css/themes/plain.css +++ b/src/workspaces/theme/css/themes/plain.css @@ -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 diff --git a/src/workspaces/theme/src/index.ts b/src/workspaces/theme/src/index.ts index 62759562..e17c722e 100644 --- a/src/workspaces/theme/src/index.ts +++ b/src/workspaces/theme/src/index.ts @@ -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'; diff --git a/src/workspaces/theme/src/useThemeAsset.ts b/src/workspaces/theme/src/useThemeAsset.ts new file mode 100644 index 00000000..454711d9 --- /dev/null +++ b/src/workspaces/theme/src/useThemeAsset.ts @@ -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; +}