Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions apps/studio/src/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -44,6 +45,20 @@ export function SettingsPage({ tab, onTab, theme, onTheme, mask, onMask }: Props
<Seg label="Frame mode" value={mask} onChange={onMask} options={FRAME_MODES.map((m) => ({ id: m, label: frameModeLabel(m) }))} />
</Row>
</Section>
<Section icon="palette-outline" title="Stage background">
<Row label="All stages" help="Behind the photos on every stage, thumbnail and strip. Black hides a dark lens vignette or a dark photo edge; a grey shows where the picture ends.">
<StageColor value={s.stageBg} fallback="Theme black" onChange={(v) => updateSettings({ stageBg: v })} />
</Row>
{STAGE_APPS.map((id) => (
<Row key={id} label={APPS[id].name} help={`Only in ${APPS[id].name} windows; otherwise as all stages.`}>
<StageColor value={s.stageBgApps[id] ?? null} fallback="As all stages" onChange={(v) => {
const next = { ...s.stageBgApps };
if (v) next[id] = v; else delete next[id];
updateSettings({ stageBgApps: next });
}} />
</Row>
))}
</Section>
<Section icon="monitor" title="Desktop">
<Row label="Recycle bin on the desktop" help="An icon you can drop files on and open. Off, the recycle bin is still in Files, at the bottom of its side bar.">
<Seg label="Recycle bin on the desktop" value={s.binOnDesktop ? "on" : "off"} onChange={(v) => updateSettings({ binOnDesktop: v === "on" })} options={[{ id: "on", label: "Show" }, { id: "off", label: "Hide" }]} />
Expand Down Expand Up @@ -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 (
<div class="stage-color">
{STAGE_SWATCHES.map((w) => (
<button key={w.hex} type="button" class={cx("stage-color__sw", value === w.hex && "is-on")} style={{ background: w.hex }} title={w.label} aria-label={w.label} aria-pressed={value === w.hex} onClick={() => onChange(w.hex)} />
))}
<input type="color" class="stage-color__pick" aria-label="Pick a colour" title="Pick a colour" value={value ?? "#000000"} onChange={(e) => onChange(hexOrNull((e.target as HTMLInputElement).value))} />
<Button kind={value ? "secondary" : "ghost"} size="sm" disabled={!value} title={`Back to: ${fallback}`} onClick={() => onChange(null)}>{value ? "Default" : fallback}</Button>
</div>
);
}

function Section({ icon, title, children }: { icon: string; title: string; children: ComponentChildren }) {
return <section class="settings__sec"><h2><Icon name={icon} />{title}</h2>{children}</section>;
}
Expand Down
15 changes: 14 additions & 1 deletion apps/studio/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
}
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 {
Expand All @@ -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 }; }
}
Expand Down
5 changes: 5 additions & 0 deletions apps/studio/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
8 changes: 7 additions & 1 deletion apps/studio/src/wm/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -211,7 +217,7 @@ function DesktopShell({ projectId, ready }: { projectId: string; ready: boolean
if (!def) return null;
const Comp = def.component;
return (
<Window key={w.id} win={w} icon={def.icon} focused={w.id === focusedId} away={w.ws !== activeWs}>
<Window key={w.id} win={w} icon={def.icon} focused={w.id === focusedId} away={w.ws !== activeWs} stageBg={settings.stageBgApps[w.app]}>
<Comp win={w} focused={w.id === focusedId} resource={w.resource} setTitle={(t) => wm.setTitle(w.id, t)}
replaceRoute={(r) => wm.setRoute(w.id, routeHash(r))}
open={(intent) => launch(intent)} close={() => void requestClose(w.id)} />
Expand Down
4 changes: 2 additions & 2 deletions apps/studio/src/wm/Window.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(null);
const barMenu = useCtxMenu<null>();
const { onBarPointerDown, onHandlePointerDown } = useWindowDrag(win, ref);
Expand All @@ -23,7 +23,7 @@ export function Window({ win, icon, focused, away, children }: { win: Win; icon:
<div
ref={ref}
class={cx("wm-window", focused && "is-focused", away && "wm-window--away", win.mode === "max" && "wm-window--max", win.mode === "min" && "wm-window--min", win.mode === "snapped" && `wm-window--snap-${win.snap}`)}
style={normal ? { left: `${win.bounds.x}px`, top: `${win.bounds.y}px`, width: `${win.bounds.w}px`, height: `${win.bounds.h}px`, zIndex: win.z } : { zIndex: win.z }}
style={{ ...(normal ? { left: `${win.bounds.x}px`, top: `${win.bounds.y}px`, width: `${win.bounds.w}px`, height: `${win.bounds.h}px`, zIndex: win.z } : { zIndex: win.z }), ...(stageBg ? { "--photo-bg": stageBg } : {}) }}
data-win-id={win.id}
role="group"
aria-label={win.title}
Expand Down
2 changes: 1 addition & 1 deletion docs/features/desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ The Studio is a desktop scoped to one project (epic #72, 2026-10-16): files on i
| Locations | `apps/LibraryApps.tsx` + `Locations.tsx` | Filterable table, inline rename (F2), the pin (Set position `P`, see [positions](positions.md)), counts that open the Gallery filtered to the location, delete |
| Viewfinder layouts | `apps/LibraryApps.tsx` + `ViewfinderLayouts.tsx` | [Viewfinder layouts](viewfinder-layouts.md); the signed-in user's own, shared by all projects |
| Recycle bin | `apps/TrashApp.tsx`, `apps/trashOps.ts` | What was deleted in the project: restore, delete permanently, empty ([files](files.md#recycle-bin-epic-90)) |
| Settings | `apps/SettingsApp.tsx` + `SettingsPage.tsx`, `settings.ts` | Preferences saved on change, per browser (`localStorage["settings"]`; theme and frame mode keep their own keys): theme, default frame mode, the recycle bin on the desktop, the review state of shots made with New shot (Approved by default), and Timeline → the playhead while a clip is edited inline and what Remove takes on a sequence block's clip. **About**: this Studio's version and commit next to the server's (`/api/ping`; they differ while a tab still has an older bundle), a link to the release notes. Also `#/settings/shortcuts`. |
| Settings | `apps/SettingsApp.tsx` + `SettingsPage.tsx`, `settings.ts` | Preferences saved on change, per browser (`localStorage["settings"]`; theme and frame mode keep their own keys): theme, default frame mode, the **stage background** (the colour behind photos on every stage, thumbnail and strip, `--photo-bg`: one for all stages, set on the page, and optionally one per app for Shot, Timeline, Gallery, Plan, Metadata, Files, Rigs, Viewfinder layouts and Recycle bin, set on that app's windows; swatches or a colour picker, Default goes back to the theme's black; a grey makes a dark lens vignette visible), the recycle bin on the desktop, the review state of shots made with New shot (Approved by default), and Timeline → the playhead while a clip is edited inline and what Remove takes on a sequence block's clip. **About**: this Studio's version and commit next to the server's (`/api/ping`; they differ while a tab still has an older bundle), a link to the release notes. Also `#/settings/shortcuts`. |

An app is a thin wrapper: it reads the shared data store (`data.ts`: one copy for every window, so an edit in one shows in all), keeps what the old `App.tsx` held for its page, reports its title and route, and opens other apps (`open`). The frame mode is one value for every window (`frameMode.ts`, `localStorage["maskMode"]`); a shot window may change its own while it is open.

Expand Down
Loading