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; +}