From 28e8be285cffbe451b29b5463a654b1753fad472 Mon Sep 17 00:00:00 2001 From: bubulux Date: Tue, 27 Oct 2026 16:21:51 +0100 Subject: [PATCH] Settings: stage background, for all stages and per app The colour behind photos (--photo-bg) is black in both themes, so a dark lens vignette or photo edge disappears into it. One colour for the page, and an optional one per app set on its windows. Co-Authored-By: Claude Opus 5.5 --- apps/studio/src/SettingsPage.tsx | 37 ++++++++++++++++++++++++++++++-- apps/studio/src/settings.ts | 15 ++++++++++++- apps/studio/src/styles.css | 5 +++++ apps/studio/src/wm/Shell.tsx | 8 ++++++- apps/studio/src/wm/Window.tsx | 4 ++-- docs/features/desktop.md | 2 +- 6 files changed, 64 insertions(+), 7 deletions(-) diff --git a/apps/studio/src/SettingsPage.tsx b/apps/studio/src/SettingsPage.tsx index 82abe90..5e3477b 100644 --- a/apps/studio/src/SettingsPage.tsx +++ b/apps/studio/src/SettingsPage.tsx @@ -4,10 +4,11 @@ import { fetchServerBuild, type ServerBuild } from "./api"; import { AccountSettings } from "./AccountSettings"; import { FRAME_MODES, frameModeLabel } from "./format"; import type { MaskMode } from "./Framed"; -import { DEFAULT_SETTINGS, updateSettings, useSettings } from "./settings"; +import { DEFAULT_SETTINGS, hexOrNull, updateSettings, useSettings } from "./settings"; +import { APPS, type AppId } from "./apps/registry"; import { ShortcutList } from "./CommandPalette"; import type { SettingsTab } from "./router"; -import { Icon, LinkButton, Seg, type ThemeChoice, Toolbar, ToolbarTitle } from "@openclapper/ui"; +import { Button, cx, Icon, LinkButton, Seg, type ThemeChoice, Toolbar, ToolbarTitle } from "@openclapper/ui"; interface Props { tab: SettingsTab; onTab: (t: SettingsTab) => void; theme: ThemeChoice; onTheme: (t: ThemeChoice) => void; mask: MaskMode; onMask: (m: MaskMode) => void } @@ -44,6 +45,20 @@ export function SettingsPage({ tab, onTab, theme, onTheme, mask, onMask }: Props ({ id: m, label: frameModeLabel(m) }))} /> +
+ + updateSettings({ stageBg: v })} /> + + {STAGE_APPS.map((id) => ( + + { + const next = { ...s.stageBgApps }; + if (v) next[id] = v; else delete next[id]; + updateSettings({ stageBgApps: next }); + }} /> + + ))} +
updateSettings({ binOnDesktop: v === "on" })} options={[{ id: "on", label: "Show" }, { id: "off", label: "Hide" }]} /> @@ -104,6 +119,24 @@ function About() { ); } +/** The apps that show photos on a stage or as thumbnails: each can have its own background. */ +const STAGE_APPS: AppId[] = ["shot", "timeline", "gallery", "plan", "metadata", "files", "rigs", "viewfinder", "trash"]; +/** Quick picks; any other colour through the picker. Stored values, not theme colours. */ +const STAGE_SWATCHES = [{ hex: "#000000", label: "Black" }, { hex: "#1f1f1f", label: "Near black" }, { hex: "#404040", label: "Dark grey" }, { hex: "#808080", label: "Mid grey" }, { hex: "#ffffff", label: "White" }]; + +/** A stage colour: swatches, a colour picker, and Default (null: the fallback named). */ +function StageColor({ value, fallback, onChange }: { value: string | null; fallback: string; onChange: (v: string | null) => void }) { + return ( +
+ {STAGE_SWATCHES.map((w) => ( + +
+ ); +} + function Section({ icon, title, children }: { icon: string; title: string; children: ComponentChildren }) { return

{title}

{children}
; } diff --git a/apps/studio/src/settings.ts b/apps/studio/src/settings.ts index e6ccae6..72be2bf 100644 --- a/apps/studio/src/settings.ts +++ b/apps/studio/src/settings.ts @@ -28,8 +28,19 @@ export interface Settings { taskbarPosition: "bottom" | "top" | "left" | "right"; /** Launcher, pins and windows from the left, or centred like Windows 11. */ taskbarAlign: "start" | "center"; + /** + * The background behind photos on every stage, thumbnail and strip (`--photo-bg`), as #rrggbb; null + * keeps the theme's black. A dark lens vignette or a dark photo edge is hard to tell from black. + */ + stageBg: string | null; + /** Per app (registry id): its own stage background, overriding `stageBg` inside its windows. */ + stageBgApps: Record; } -export const DEFAULT_SETTINGS: Settings = { newShotState: "approved", inlinePlayhead: "pin", seqRemove: "photo", autoOverlay: false, binOnDesktop: true, taskbarPosition: "bottom", taskbarAlign: "start" }; +export const DEFAULT_SETTINGS: Settings = { newShotState: "approved", inlinePlayhead: "pin", seqRemove: "photo", autoOverlay: false, binOnDesktop: true, taskbarPosition: "bottom", taskbarAlign: "start", stageBg: null, stageBgApps: {} }; + +const HEX = /^#[0-9a-f]{6}$/i; +/** A stored colour, or null when it is not #rrggbb (hand-edited storage never reaches the CSS). */ +export const hexOrNull = (v: unknown): string | null => (typeof v === "string" && HEX.test(v) ? v.toLowerCase() : null); function read(): Settings { try { @@ -42,6 +53,8 @@ function read(): Settings { binOnDesktop: raw.binOnDesktop !== false, taskbarPosition: raw.taskbarPosition === "top" || raw.taskbarPosition === "left" || raw.taskbarPosition === "right" ? raw.taskbarPosition : "bottom", taskbarAlign: raw.taskbarAlign === "center" ? "center" : "start", + stageBg: hexOrNull(raw.stageBg), + stageBgApps: Object.fromEntries(Object.entries(raw.stageBgApps && typeof raw.stageBgApps === "object" ? raw.stageBgApps : {}).flatMap(([k, v]) => { const h = hexOrNull(v); return h ? [[k, h]] : []; })), }; } catch { return { ...DEFAULT_SETTINGS }; } } diff --git a/apps/studio/src/styles.css b/apps/studio/src/styles.css index bb438a0..ce523b4 100644 --- a/apps/studio/src/styles.css +++ b/apps/studio/src/styles.css @@ -352,6 +352,11 @@ button { font: inherit; } .settings__row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-4); align-items: center; padding: 14px 16px; border-bottom: var(--bw) solid var(--border-subtle); } .settings__row:last-child { border-bottom: 0; } .settings__row > div:first-child { display: flex; flex-direction: column; gap: 2px; } +/* Stage background (Settings): swatches carry their own colour as data; the ring uses the tokens. */ +.stage-color { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; } +.stage-color__sw { width: 22px; height: 22px; border-radius: 50%; padding: 0; cursor: pointer; border: var(--bw) solid var(--border-strong); } +.stage-color__sw.is-on { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--accent); } +.stage-color__pick { width: 30px; height: 24px; padding: 0; border: var(--bw) solid var(--border-strong); border-radius: var(--radius-sm); background: none; cursor: pointer; } /* Settings › Account (AccountSettings.tsx): a workspace row holds its members and the invite form. */ .settings__ws { align-items: start; } .settings__wsact { display: flex; flex-direction: column; align-items: flex-end; gap: var(--space-2); } diff --git a/apps/studio/src/wm/Shell.tsx b/apps/studio/src/wm/Shell.tsx index 9c4c304..8767c23 100644 --- a/apps/studio/src/wm/Shell.tsx +++ b/apps/studio/src/wm/Shell.tsx @@ -119,6 +119,12 @@ function DesktopShell({ projectId, ready }: { projectId: string; ready: boolean const activeWs = wm.useActiveWs(); const split = wm.useSplit(); const settings = useSettings(); + // The stage background (Settings › Appearance): the global choice replaces the theme's on the page; + // an app's own choice is set on its windows (Window `stageBg`). + useEffect(() => { + const root = document.documentElement.style; + if (settings.stageBg) root.setProperty("--photo-bg", settings.stageBg); else root.removeProperty("--photo-bg"); + }, [settings.stageBg]); const focusedId = wm.useFocusedId(); const preview = wm.useSnapPreview(); const overlay = useOverlay(); @@ -211,7 +217,7 @@ function DesktopShell({ projectId, ready }: { projectId: string; ready: boolean if (!def) return null; const Comp = def.component; return ( - + wm.setTitle(w.id, t)} replaceRoute={(r) => wm.setRoute(w.id, routeHash(r))} open={(intent) => launch(intent)} close={() => void requestClose(w.id)} /> diff --git a/apps/studio/src/wm/Window.tsx b/apps/studio/src/wm/Window.tsx index 5e5e186..c91fe77 100644 --- a/apps/studio/src/wm/Window.tsx +++ b/apps/studio/src/wm/Window.tsx @@ -14,7 +14,7 @@ import type { Win } from "./wm"; * inline left/top/width/height in normal mode, CSS classes for max/snapped/min. A window of * another workspace stays mounted but hidden (`away`), so its app keeps its state. */ -export function Window({ win, icon, focused, away, children }: { win: Win; icon: string; focused: boolean; away?: boolean; children: ComponentChildren }) { +export function Window({ win, icon, focused, away, stageBg, children }: { win: Win; icon: string; focused: boolean; away?: boolean; /** The app's own stage background (Settings), else the page's. */ stageBg?: string; children: ComponentChildren }) { const ref = useRef(null); const barMenu = useCtxMenu(); const { onBarPointerDown, onHandlePointerDown } = useWindowDrag(win, ref); @@ -23,7 +23,7 @@ export function Window({ win, icon, focused, away, children }: { win: Win; icon: