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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |

---
Expand Down
148 changes: 113 additions & 35 deletions server/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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;
}

8 changes: 7 additions & 1 deletion server/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { THEME_BOOT_SCRIPT } from "@/lib/theme";
import "./globals.css";

export const metadata: Metadata = {
Expand All @@ -13,7 +14,12 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" data-theme="dark" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{ __html: THEME_BOOT_SCRIPT }}
/>
</head>
<body className="min-h-full antialiased">{children}</body>
</html>
);
Expand Down
4 changes: 4 additions & 0 deletions server/app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import Link from "next/link";
import { ThemeToggle } from "@/components/ThemeToggle";

export default function NotFound() {
return (
<main className="mx-auto flex min-h-full max-w-md flex-col justify-center px-6 py-16 text-center">
<div className="mb-4 flex justify-end">
<ThemeToggle compact />
</div>
<h1 className="mb-2 text-2xl font-semibold text-vscode-fg">Not found</h1>
<p className="text-sm text-vscode-muted">
This paste does not exist or has expired.
Expand Down
5 changes: 5 additions & 0 deletions server/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { ThemeToggle } from "@/components/ThemeToggle";

export default function HomePage() {
return (
<main className="mx-auto flex min-h-full max-w-3xl flex-col gap-8 px-6 py-16">
<div className="flex justify-end">
<ThemeToggle compact />
</div>
<header className="space-y-3">
<p className="font-mono text-sm text-vscode-accent">PaperCut</p>
<h1 className="text-3xl font-semibold tracking-tight text-vscode-fg sm:text-4xl">
Expand Down
2 changes: 2 additions & 0 deletions server/components/LogCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down Expand Up @@ -151,6 +152,7 @@ export function LogCanvas({
{expiresAt ? (
<span title="Expires">exp {new Date(expiresAt).toLocaleString()}</span>
) : null}
<ThemeToggle compact />
</div>
</header>

Expand Down
4 changes: 4 additions & 0 deletions server/components/PasswordGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useRouter } from "next/navigation";
import { FormEvent, useState } from "react";
import { ThemeToggle } from "./ThemeToggle";

interface PasswordGateProps {
pasteId: string;
Expand Down Expand Up @@ -40,6 +41,9 @@ export function PasswordGate({ pasteId }: PasswordGateProps) {

return (
<main className="mx-auto flex min-h-full max-w-md flex-col justify-center px-6 py-16">
<div className="mb-4 flex justify-end">
<ThemeToggle compact />
</div>
<div className="rounded-lg border border-vscode-border bg-vscode-sidebar p-6 shadow-xl">
<h1 className="mb-1 text-lg font-semibold text-vscode-fg">
Protected paste
Expand Down
62 changes: 62 additions & 0 deletions server/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
@@ -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<Theme>("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 (
<button
type="button"
onClick={toggle}
className={
className ||
"rounded border border-vscode-border bg-vscode-line px-2 py-1 font-mono text-xs text-vscode-fg hover:border-vscode-accent"
}
aria-label={label}
title={label}
>
{!mounted ? (
<span className="text-vscode-muted">Theme</span>
) : theme === "dark" ? (
<span>{compact ? "☀ Light" : "☀ Light theme"}</span>
) : (
<span>{compact ? "☾ Dark" : "☾ Dark theme"}</span>
)}
</button>
);
}
16 changes: 16 additions & 0 deletions server/lib/theme.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading