diff --git a/CHANGELOG.md b/CHANGELOG.md
index ac8a4dc..c08596f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,10 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- GitHub Actions **Publish npm** workflow — auto-publish `papercut-cli` on GitHub Release via [npm trusted publishers](https://docs.npmjs.com/trusted-publishers) (OIDC; no long-lived token)
+- Light/dark theme toggle (VS Code–style palettes, `localStorage` persistence)
### Planned
-See [ROADMAP.md](./ROADMAP.md) for remaining v1.1+ items (metrics, theme toggle, scale features, etc.).
+See [ROADMAP.md](./ROADMAP.md) for remaining v1.1+ items (opt-in metrics, scale features, etc.).
## [1.1.0] - 2026-07-15
diff --git a/ROADMAP.md b/ROADMAP.md
index bec7a30..2d27464 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -37,7 +37,7 @@ Focus: production operators and contributors.
| 1.1.3 | **CLI publish dry-run** / npm package readiness | cli | ✅ Done (`cli/PUBLISH.md` + CI `cli-pack`) |
| 1.1.4 | **Structured app metrics (opt-in)** | server | Counters only; no content; disabled by default |
| 1.1.5 | **Graceful DB backup note** + `sqlite3 .backup` recipe | docker, docs | ✅ Done (`docs/deploy`) |
-| 1.1.6 | **Dark/light toggle** (still VS Code aesthetic) | ui | Preference in `localStorage` |
+| 1.1.6 | **Dark/light toggle** (still VS Code aesthetic) | ui | ✅ Done (`localStorage` + `data-theme`) |
| 1.1.7 | **Reverse proxy + HTTPS docs** (nginx, Caddy, Traefik) | docker, docs | ✅ Done (`docs/deploy`) |
---
diff --git a/server/app/globals.css b/server/app/globals.css
index 2fce8ab..9957830 100644
--- a/server/app/globals.css
+++ b/server/app/globals.css
@@ -2,19 +2,47 @@
@tailwind components;
@tailwind utilities;
-:root {
- --bg: #1e1e1e;
- --sidebar: #252526;
- --line: #2d2d2d;
- --border: #3c3c3c;
- --fg: #d4d4d4;
- --muted: #858585;
- --accent: #007acc;
- --error: #f44747;
- --warn: #cca700;
- --info: #3794ff;
- --debug: #808080;
- --success: #89d185;
+/*
+ VS Code–inspired themes as RGB channels so Tailwind opacity modifiers work
+ (e.g. bg-vscode-line/40).
+*/
+:root,
+[data-theme="dark"] {
+ --vscode-bg: 30 30 30;
+ --vscode-sidebar: 37 37 38;
+ --vscode-line: 45 45 45;
+ --vscode-border: 60 60 60;
+ --vscode-fg: 212 212 212;
+ --vscode-muted: 133 133 133;
+ --vscode-accent: 0 122 204;
+ --vscode-error: 244 71 71;
+ --vscode-warn: 204 167 0;
+ --vscode-info: 55 148 255;
+ --vscode-debug: 128 128 128;
+ --vscode-success: 137 209 133;
+ --vscode-selection: 38 79 120;
+ --vscode-gutter: 133 133 133;
+ --selection-fg: 255 255 255;
+ color-scheme: dark;
+}
+
+[data-theme="light"] {
+ --vscode-bg: 255 255 255;
+ --vscode-sidebar: 243 243 243;
+ --vscode-line: 232 232 232;
+ --vscode-border: 206 206 206;
+ --vscode-fg: 51 51 51;
+ --vscode-muted: 110 110 110;
+ --vscode-accent: 0 120 212;
+ --vscode-error: 205 49 49;
+ --vscode-warn: 137 110 0;
+ --vscode-info: 0 102 204;
+ --vscode-debug: 110 110 110;
+ --vscode-success: 15 128 62;
+ --vscode-selection: 173 214 255;
+ --vscode-gutter: 110 110 110;
+ --selection-fg: 0 0 0;
+ color-scheme: light;
}
html,
@@ -23,8 +51,8 @@ body {
}
body {
- background: var(--bg);
- color: var(--fg);
+ background: rgb(var(--vscode-bg));
+ color: rgb(var(--vscode-fg));
font-family:
ui-sans-serif,
system-ui,
@@ -37,63 +65,113 @@ body {
}
::selection {
- background: #264f78;
- color: #fff;
+ background: rgb(var(--vscode-selection));
+ color: rgb(var(--selection-fg));
}
-/* ansi_up default classes — tune toward VS Code dark */
-.ansi-black-fg {
+/* ANSI — dark defaults */
+[data-theme="dark"] .ansi-black-fg {
color: #1e1e1e;
}
-.ansi-red-fg {
+[data-theme="dark"] .ansi-red-fg {
color: #f44747;
}
-.ansi-green-fg {
+[data-theme="dark"] .ansi-green-fg {
color: #89d185;
}
-.ansi-yellow-fg {
+[data-theme="dark"] .ansi-yellow-fg {
color: #cca700;
}
-.ansi-blue-fg {
+[data-theme="dark"] .ansi-blue-fg {
color: #3794ff;
}
-.ansi-magenta-fg {
+[data-theme="dark"] .ansi-magenta-fg {
color: #c586c0;
}
-.ansi-cyan-fg {
+[data-theme="dark"] .ansi-cyan-fg {
color: #4ec9b0;
}
-.ansi-white-fg {
+[data-theme="dark"] .ansi-white-fg {
color: #d4d4d4;
}
-.ansi-bright-black-fg {
+[data-theme="dark"] .ansi-bright-black-fg {
color: #808080;
}
-.ansi-bright-red-fg {
+[data-theme="dark"] .ansi-bright-red-fg {
color: #f44747;
}
-.ansi-bright-green-fg {
+[data-theme="dark"] .ansi-bright-green-fg {
color: #89d185;
}
-.ansi-bright-yellow-fg {
+[data-theme="dark"] .ansi-bright-yellow-fg {
color: #dcdcaa;
}
-.ansi-bright-blue-fg {
+[data-theme="dark"] .ansi-bright-blue-fg {
color: #3794ff;
}
-.ansi-bright-magenta-fg {
+[data-theme="dark"] .ansi-bright-magenta-fg {
color: #c586c0;
}
-.ansi-bright-cyan-fg {
+[data-theme="dark"] .ansi-bright-cyan-fg {
color: #4ec9b0;
}
-.ansi-bright-white-fg {
+[data-theme="dark"] .ansi-bright-white-fg {
color: #ffffff;
}
+
+/* ANSI — light (VS Code Light+) */
+[data-theme="light"] .ansi-black-fg {
+ color: #000000;
+}
+[data-theme="light"] .ansi-red-fg {
+ color: #cd3131;
+}
+[data-theme="light"] .ansi-green-fg {
+ color: #0f8040;
+}
+[data-theme="light"] .ansi-yellow-fg {
+ color: #949800;
+}
+[data-theme="light"] .ansi-blue-fg {
+ color: #0451a5;
+}
+[data-theme="light"] .ansi-magenta-fg {
+ color: #bc05bc;
+}
+[data-theme="light"] .ansi-cyan-fg {
+ color: #0598bc;
+}
+[data-theme="light"] .ansi-white-fg {
+ color: #555555;
+}
+[data-theme="light"] .ansi-bright-black-fg {
+ color: #666666;
+}
+[data-theme="light"] .ansi-bright-red-fg {
+ color: #cd3131;
+}
+[data-theme="light"] .ansi-bright-green-fg {
+ color: #14ce14;
+}
+[data-theme="light"] .ansi-bright-yellow-fg {
+ color: #b5ba00;
+}
+[data-theme="light"] .ansi-bright-blue-fg {
+ color: #0451a5;
+}
+[data-theme="light"] .ansi-bright-magenta-fg {
+ color: #bc05bc;
+}
+[data-theme="light"] .ansi-bright-cyan-fg {
+ color: #0598bc;
+}
+[data-theme="light"] .ansi-bright-white-fg {
+ color: #a5a5a5;
+}
+
.ansi-bold {
font-weight: 700;
}
.ansi-underline {
text-decoration: underline;
}
-
diff --git a/server/app/layout.tsx b/server/app/layout.tsx
index bf66a3b..740aa28 100644
--- a/server/app/layout.tsx
+++ b/server/app/layout.tsx
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
+import { THEME_BOOT_SCRIPT } from "@/lib/theme";
import "./globals.css";
export const metadata: Metadata = {
@@ -13,7 +14,12 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
-
+
+
+
+
{children}
);
diff --git a/server/app/not-found.tsx b/server/app/not-found.tsx
index 358403e..0e83410 100644
--- a/server/app/not-found.tsx
+++ b/server/app/not-found.tsx
@@ -1,8 +1,12 @@
import Link from "next/link";
+import { ThemeToggle } from "@/components/ThemeToggle";
export default function NotFound() {
return (
+
+
+
Not found
This paste does not exist or has expired.
diff --git a/server/app/page.tsx b/server/app/page.tsx
index 016010d..c73eedb 100644
--- a/server/app/page.tsx
+++ b/server/app/page.tsx
@@ -1,6 +1,11 @@
+import { ThemeToggle } from "@/components/ThemeToggle";
+
export default function HomePage() {
return (
+
+
+
PaperCut
diff --git a/server/components/LogCanvas.tsx b/server/components/LogCanvas.tsx
index 673202f..fc5f939 100644
--- a/server/components/LogCanvas.tsx
+++ b/server/components/LogCanvas.tsx
@@ -14,6 +14,7 @@ import {
type LevelFilterState,
type LineSelection,
} from "@/lib/log-lines";
+import { ThemeToggle } from "./ThemeToggle";
import { VirtualLogList } from "./VirtualLogList";
const FILTER_LEVELS: LogLevel[] = [
@@ -151,6 +152,7 @@ export function LogCanvas({
{expiresAt ? (
exp {new Date(expiresAt).toLocaleString()}
) : null}
+
diff --git a/server/components/PasswordGate.tsx b/server/components/PasswordGate.tsx
index 7cb0b21..d630b1a 100644
--- a/server/components/PasswordGate.tsx
+++ b/server/components/PasswordGate.tsx
@@ -2,6 +2,7 @@
import { useRouter } from "next/navigation";
import { FormEvent, useState } from "react";
+import { ThemeToggle } from "./ThemeToggle";
interface PasswordGateProps {
pasteId: string;
@@ -40,6 +41,9 @@ export function PasswordGate({ pasteId }: PasswordGateProps) {
return (
+
+
+
Protected paste
diff --git a/server/components/ThemeToggle.tsx b/server/components/ThemeToggle.tsx
new file mode 100644
index 0000000..25db8b0
--- /dev/null
+++ b/server/components/ThemeToggle.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import {
+ applyTheme,
+ persistTheme,
+ resolveInitialTheme,
+ type Theme,
+} from "@/lib/theme";
+
+interface ThemeToggleProps {
+ className?: string;
+ /** Compact label for toolbars */
+ compact?: boolean;
+}
+
+export function ThemeToggle({ className = "", compact = false }: ThemeToggleProps) {
+ const [theme, setTheme] = useState("dark");
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setTheme(resolveInitialTheme());
+ setMounted(true);
+ }, []);
+
+ function toggle() {
+ const next: Theme = theme === "dark" ? "light" : "dark";
+ setTheme(next);
+ applyTheme(next);
+ persistTheme(next);
+ }
+
+ const label =
+ theme === "dark"
+ ? compact
+ ? "Light"
+ : "Switch to light theme"
+ : compact
+ ? "Dark"
+ : "Switch to dark theme";
+
+ return (
+
+ {!mounted ? (
+ Theme
+ ) : theme === "dark" ? (
+ {compact ? "☀ Light" : "☀ Light theme"}
+ ) : (
+ {compact ? "☾ Dark" : "☾ Dark theme"}
+ )}
+
+ );
+}
diff --git a/server/lib/theme.test.ts b/server/lib/theme.test.ts
new file mode 100644
index 0000000..61f26be
--- /dev/null
+++ b/server/lib/theme.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from "vitest";
+import { isTheme, THEME_STORAGE_KEY } from "./theme";
+
+describe("theme helpers", () => {
+ it("accepts only dark and light", () => {
+ expect(isTheme("dark")).toBe(true);
+ expect(isTheme("light")).toBe(true);
+ expect(isTheme("auto")).toBe(false);
+ expect(isTheme("")).toBe(false);
+ expect(isTheme(null)).toBe(false);
+ });
+
+ it("uses a stable storage key", () => {
+ expect(THEME_STORAGE_KEY).toBe("papercut-theme");
+ });
+});
diff --git a/server/lib/theme.ts b/server/lib/theme.ts
new file mode 100644
index 0000000..7607af9
--- /dev/null
+++ b/server/lib/theme.ts
@@ -0,0 +1,37 @@
+export type Theme = "dark" | "light";
+
+export const THEME_STORAGE_KEY = "papercut-theme";
+
+export function isTheme(value: unknown): value is Theme {
+ return value === "dark" || value === "light";
+}
+
+export function applyTheme(theme: Theme): void {
+ if (typeof document === "undefined") return;
+ document.documentElement.setAttribute("data-theme", theme);
+}
+
+export function readStoredTheme(): Theme | null {
+ if (typeof window === "undefined") return null;
+ try {
+ const raw = window.localStorage.getItem(THEME_STORAGE_KEY);
+ return isTheme(raw) ? raw : null;
+ } catch {
+ return null;
+ }
+}
+
+export function persistTheme(theme: Theme): void {
+ try {
+ window.localStorage.setItem(THEME_STORAGE_KEY, theme);
+ } catch {
+ /* private mode / blocked storage */
+ }
+}
+
+export function resolveInitialTheme(): Theme {
+ return readStoredTheme() ?? "dark";
+}
+
+/** Inline script: set data-theme before paint (no FOUC). */
+export const THEME_BOOT_SCRIPT = `(function(){try{var k=${JSON.stringify(THEME_STORAGE_KEY)};var t=localStorage.getItem(k);if(t!=="light"&&t!=="dark")t="dark";document.documentElement.setAttribute("data-theme",t);}catch(e){document.documentElement.setAttribute("data-theme","dark");}})();`;
diff --git a/server/tailwind.config.ts b/server/tailwind.config.ts
index 14bb1c1..a7979eb 100644
--- a/server/tailwind.config.ts
+++ b/server/tailwind.config.ts
@@ -1,5 +1,8 @@
import type { Config } from "tailwindcss";
+const withAlpha = (channel: string) =>
+ `rgb(var(${channel}) / )`;
+
const config: Config = {
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
@@ -9,20 +12,20 @@ const config: Config = {
extend: {
colors: {
vscode: {
- bg: "#1e1e1e",
- sidebar: "#252526",
- line: "#2d2d2d",
- border: "#3c3c3c",
- fg: "#d4d4d4",
- muted: "#858585",
- accent: "#007acc",
- error: "#f44747",
- warn: "#cca700",
- info: "#3794ff",
- debug: "#808080",
- success: "#89d185",
- selection: "#264f78",
- gutter: "#858585",
+ bg: withAlpha("--vscode-bg"),
+ sidebar: withAlpha("--vscode-sidebar"),
+ line: withAlpha("--vscode-line"),
+ border: withAlpha("--vscode-border"),
+ fg: withAlpha("--vscode-fg"),
+ muted: withAlpha("--vscode-muted"),
+ accent: withAlpha("--vscode-accent"),
+ error: withAlpha("--vscode-error"),
+ warn: withAlpha("--vscode-warn"),
+ info: withAlpha("--vscode-info"),
+ debug: withAlpha("--vscode-debug"),
+ success: withAlpha("--vscode-success"),
+ selection: withAlpha("--vscode-selection"),
+ gutter: withAlpha("--vscode-gutter"),
},
},
fontFamily: {