+
{formatTime(duration)}
@@ -77,7 +77,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
>
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 972e7f6..d4d417e 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -1,5 +1,10 @@
@import "tailwindcss";
+/* Drive the `dark:` variant off the same data-theme attribute the toggle sets,
+ instead of Tailwind's default prefers-color-scheme. Without this a stray
+ `dark:` utility would disagree with an explicit light/dark choice. */
+@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
+
@theme {
/* ── Fonts ───────────────────────────────────────────────────────────── */
--font-body: "IBM Plex Sans", system-ui, sans-serif;
@@ -12,6 +17,10 @@
--color-fields: #F4F0E8;
--color-dropzone: #F9F6EF;
+ /* Letterbox behind video. Dark in both themes — it is a cinema surface,
+ not an ink colour — so `text-on-dark` always reads on top of it. */
+ --color-media-backdrop: #2B2620;
+
/* ── Ink ─────────────────────────────────────────────────────────────── */
--color-text-primary: #2B2620;
--color-text-strong: #35302A;
@@ -44,6 +53,55 @@
--color-inactive-strong: #C4BAA9;
}
+/* Dark mode. Only token values change here: every component already styles
+ itself with the semantic utilities above, so the whole UI follows from this
+ one block. `--color-on-dark` is deliberately absent — it is the ink on the
+ media backdrop, which stays dark in both themes. */
+:root[data-theme="dark"] {
+ color-scheme: dark;
+
+ /* ── Surfaces ── elevation inverts: the canvas is darkest, cards rise ─── */
+ --color-background: #1B1815;
+ --color-sidebar: #151210;
+ --color-card: #26211C;
+ --color-fields: #211D19;
+ --color-dropzone: #201C18;
+
+ --color-media-backdrop: #121010;
+
+ /* ── Ink ── warm off-whites, mirroring the light ramp in reverse ──────── */
+ --color-text-primary: #F3EDE3;
+ --color-text-strong: #E6DFD3;
+ --color-text-body: #CFC6B8;
+ --color-text-secondary: #ABA192;
+ --color-muted: #948A7C;
+ --color-faint-mono: #8A8073;
+
+ /* ── Accents ── brightened to carry on a dark canvas, so hover now lifts
+ rather than deepens ─────────────────────────────────────────────────── */
+ --color-terracotta: #D08055;
+ --color-terracotta-hover: #E09A6F;
+ --color-terracotta-light: #E3A87F;
+ --color-terracotta-disabled: #8A6A55;
+ --color-olive: #9DAC77;
+
+ /* ── Status ──────────────────────────────────────────────────────────── */
+ --color-live: #E0705A;
+ --color-error: #E0705A;
+
+ /* ── Ink on accent surfaces ── the accents are light here, so the ink that
+ sits on them flips dark ─────────────────────────────────────────────── */
+ --color-on-accent: #241B14;
+
+ /* ── Lines, chips, placeholders ──────────────────────────────────────── */
+ --color-hairline: #2E2822;
+ --color-hover: #322B24;
+ --color-selected: #2E2119;
+ --color-ring: #3E362C;
+ --color-inactive: #3B342B;
+ --color-inactive-strong: #6B6153;
+}
+
:root {
font-family: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.5;
@@ -52,6 +110,7 @@
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
+ color-scheme: light;
}
body {
@@ -66,6 +125,16 @@ body {
color: var(--color-text-primary);
}
+/* Google Identity Services renders its sign-in button in a cross-origin iframe
+ whose document is light-scheme with a transparent background. Left to inherit
+ our `color-scheme: dark`, Chrome cannot honour dark there and paints an opaque
+ white canvas behind it instead — framing the pill in a white box on every dark
+ page. Pinning the iframe back to light keeps it transparent; the button's own
+ colours come from the `theme` prop in Topbar.tsx. */
+iframe[src*="accounts.google.com/gsi/"] {
+ color-scheme: light;
+}
+
/* Clip trim slider (react-range-slider-input). Its own stylesheet ships blue
defaults; the #id beats those class selectors regardless of import order. */
#range-slider {
@@ -87,4 +156,4 @@ body {
#range-slider .range-slider__thumb:focus-visible {
box-shadow: 0 0 0 4px color-mix(in oklab, var(--color-terracotta) 35%, transparent);
-}
\ No newline at end of file
+}
diff --git a/frontend/src/pages/ClipEdit.tsx b/frontend/src/pages/ClipEdit.tsx
index f40362e..3188ef3 100644
--- a/frontend/src/pages/ClipEdit.tsx
+++ b/frontend/src/pages/ClipEdit.tsx
@@ -135,7 +135,7 @@ const ClipEdit = () => {
diff --git a/frontend/src/pages/MediaPlayer.tsx b/frontend/src/pages/MediaPlayer.tsx
index a27111f..096b1ba 100644
--- a/frontend/src/pages/MediaPlayer.tsx
+++ b/frontend/src/pages/MediaPlayer.tsx
@@ -233,7 +233,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
{/* Player */}
isPlaying && setControlsVisible(false)}
onClick={togglePlay}
diff --git a/frontend/src/theme/ThemeContext.ts b/frontend/src/theme/ThemeContext.ts
new file mode 100644
index 0000000..9bbd90d
--- /dev/null
+++ b/frontend/src/theme/ThemeContext.ts
@@ -0,0 +1,23 @@
+import { createContext } from "react";
+
+/** What the user chose. "system" defers to the OS setting. */
+type ThemePreference = "light" | "dark" | "system";
+
+/** The theme actually on screen, once "system" has been resolved. */
+type ResolvedTheme = "light" | "dark";
+
+type ThemeContextValue = {
+ /** The stored choice, which may be "system". */
+ preference: ThemePreference;
+ /** The theme currently applied to the document. */
+ theme: ResolvedTheme;
+ /** Store a new preference and apply it immediately. */
+ setPreference: (preference: ThemePreference) => void;
+ /** Flip between light and dark, pinning the result as an explicit choice. */
+ toggleTheme: () => void;
+};
+
+const ThemeContext = createContext(null);
+
+export { ThemeContext };
+export type { ThemeContextValue, ThemePreference, ResolvedTheme };
diff --git a/frontend/src/theme/ThemeProvider.tsx b/frontend/src/theme/ThemeProvider.tsx
new file mode 100644
index 0000000..0fae106
--- /dev/null
+++ b/frontend/src/theme/ThemeProvider.tsx
@@ -0,0 +1,71 @@
+import { useCallback, useEffect, useState } from "react";
+import type { ReactNode } from "react";
+import { ThemeContext } from "./ThemeContext.ts";
+import type { ResolvedTheme, ThemePreference } from "./ThemeContext.ts";
+
+/** Shared with the pre-paint script in index.html — keep the two in step. */
+const STORAGE_KEY = "theme";
+const DARK_QUERY = "(prefers-color-scheme: dark)";
+
+const readPreference = (): ThemePreference => {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored === "light" || stored === "dark" || stored === "system") {
+ return stored;
+ }
+ } catch {
+ // Storage blocked (private browsing, cookies disabled) — follow the OS.
+ }
+ return "system";
+};
+
+const readSystemTheme = (): ResolvedTheme =>
+ window.matchMedia(DARK_QUERY).matches ? "dark" : "light";
+
+/**
+ * Owns the single source of truth for the active theme. Nothing reads colours
+ * from here: the palette lives in index.css and switches on the `data-theme`
+ * attribute this provider writes, so components keep using the semantic
+ * utilities (`bg-card`, `text-text-primary`) and follow along for free.
+ */
+const ThemeProvider = ({ children }: { children: ReactNode }) => {
+ const [preference, setPreferenceState] = useState(readPreference);
+ const [systemTheme, setSystemTheme] = useState(readSystemTheme);
+
+ // Keep following the OS while the preference is "system".
+ useEffect(() => {
+ const query = window.matchMedia(DARK_QUERY);
+ const onChange = (event: MediaQueryListEvent) =>
+ setSystemTheme(event.matches ? "dark" : "light");
+
+ query.addEventListener("change", onChange);
+ return () => query.removeEventListener("change", onChange);
+ }, []);
+
+ const theme: ResolvedTheme = preference === "system" ? systemTheme : preference;
+
+ useEffect(() => {
+ document.documentElement.dataset.theme = theme;
+ }, [theme]);
+
+ const setPreference = useCallback((next: ThemePreference) => {
+ setPreferenceState(next);
+ try {
+ localStorage.setItem(STORAGE_KEY, next);
+ } catch {
+ // Storage blocked — the choice still applies for this session.
+ }
+ }, []);
+
+ const toggleTheme = useCallback(() => {
+ setPreference(theme === "dark" ? "light" : "dark");
+ }, [theme, setPreference]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default ThemeProvider;
diff --git a/frontend/src/theme/useTheme.ts b/frontend/src/theme/useTheme.ts
new file mode 100644
index 0000000..0828e03
--- /dev/null
+++ b/frontend/src/theme/useTheme.ts
@@ -0,0 +1,14 @@
+import { useContext } from "react";
+import { ThemeContext } from "./ThemeContext.ts";
+import type { ThemeContextValue } from "./ThemeContext.ts";
+
+/** Access the centralised theme state. Must be used within a . */
+const useTheme = (): ThemeContextValue => {
+ const context = useContext(ThemeContext);
+ if (!context) {
+ throw new Error("useTheme must be used within a ThemeProvider");
+ }
+ return context;
+};
+
+export { useTheme };