From 01567552f8873d7b277e27863a95d2fc48323040 Mon Sep 17 00:00:00 2001
From: Catalin Irimie
Date: Sat, 27 Jun 2026 00:26:03 +0300
Subject: [PATCH 1/5] feat: add dark / system theme toggle
Add a three-way Light / System / Dark theme switch, defaulting to the
visitor's OS preference.
- next-themes ThemeProvider using the class strategy; no flash via its
pre-paint script plus suppressHydrationWarning on
- ThemeToggle in the app top bar and the marketing footer
- Dark palette defined as CSS variables under .dark (P3 + sRGB fallback), so
the variable-driven UI flips with no per-component changes
- Dark highlight.js syntax tokens, plus dark: variants for the hardcoded
status colors (diff views, error box)
- Make the CodeMirror editor and Mermaid diagrams theme-aware
---
package.json | 1 +
pnpm-lock.yaml | 54 ++++++++++
src/app/error.tsx | 6 +-
src/app/globals.css | 145 +++++++++++++++++++++++++++
src/app/layout.tsx | 11 +-
src/components/Footer.tsx | 4 +
src/components/HourSelect.tsx | 2 +-
src/components/MermaidDiagram.tsx | 28 +++---
src/components/RFCMarkdownEditor.tsx | 6 ++
src/components/RFCsTopBar.tsx | 26 ++---
src/components/RfcDiffView.tsx | 16 +--
src/components/ThemeToggle.tsx | 107 ++++++++++++++++++++
src/components/TimezoneSelect.tsx | 2 +-
src/providers/ThemeProvider.tsx | 19 ++++
14 files changed, 385 insertions(+), 42 deletions(-)
create mode 100644 src/components/ThemeToggle.tsx
create mode 100644 src/providers/ThemeProvider.tsx
diff --git a/package.json b/package.json
index 5d4ef71..cc56fa1 100644
--- a/package.json
+++ b/package.json
@@ -49,6 +49,7 @@
"motion": "^12.38.0",
"next": "16.0.10",
"next-auth": "5.0.0-beta.29",
+ "next-themes": "^0.4.6",
"octokit": "^5.0.4",
"posthog-js": "^1.280.1",
"posthog-node": "^5.10.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f82182a..cc51f7c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -95,6 +95,9 @@ importers:
next-auth:
specifier: 5.0.0-beta.29
version: 5.0.0-beta.29(next@16.0.10(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)
+ next-themes:
+ specifier: ^0.4.6
+ version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
octokit:
specifier: ^5.0.4
version: 5.0.4
@@ -287,24 +290,28 @@ packages:
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@biomejs/cli-linux-arm64@2.2.0':
resolution: {integrity: sha512-6eoRdF2yW5FnW9Lpeivh7Mayhq0KDdaDMYOJnH9aT02KuSIX5V1HmWJCQQPwIQbhDh68Zrcpl8inRlTEan0SXw==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@biomejs/cli-linux-x64-musl@2.2.0':
resolution: {integrity: sha512-I5J85yWwUWpgJyC1CcytNSGusu2p9HjDnOPAFG4Y515hwRD0jpR9sT9/T1cKHtuCvEQ/sBvx+6zhz9l9wEJGAg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@biomejs/cli-linux-x64@2.2.0':
resolution: {integrity: sha512-5UmQx/OZAfJfi25zAnAGHUMuOd+LOsliIt119x2soA2gLggQYrVPA+2kMUxR6Mw5M1deUF/AWWP2qpxgH7Nyfw==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@biomejs/cli-win32-arm64@2.2.0':
resolution: {integrity: sha512-n9a1/f2CwIDmNMNkFs+JI0ZjFnMO0jdOyGNtihgUNFnlmd84yIYY2KMTBmMV58ZlVHjgmY5Y6E1hVTnSRieggA==}
@@ -631,78 +638,92 @@ packages:
resolution: {integrity: sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.3':
resolution: {integrity: sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.3':
resolution: {integrity: sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.3':
resolution: {integrity: sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.3':
resolution: {integrity: sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.3':
resolution: {integrity: sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.3':
resolution: {integrity: sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@img/sharp-linux-arm64@0.34.4':
resolution: {integrity: sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-arm@0.34.4':
resolution: {integrity: sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-ppc64@0.34.4':
resolution: {integrity: sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-s390x@0.34.4':
resolution: {integrity: sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-x64@0.34.4':
resolution: {integrity: sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.4':
resolution: {integrity: sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.4':
resolution: {integrity: sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@img/sharp-wasm32@0.34.4':
resolution: {integrity: sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==}
@@ -863,24 +884,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@next/swc-linux-arm64-musl@16.0.10':
resolution: {integrity: sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@next/swc-linux-x64-gnu@16.0.10':
resolution: {integrity: sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@next/swc-linux-x64-musl@16.0.10':
resolution: {integrity: sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@next/swc-win32-arm64-msvc@16.0.10':
resolution: {integrity: sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg==}
@@ -1551,36 +1576,42 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.1':
resolution: {integrity: sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.1':
resolution: {integrity: sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.1':
resolution: {integrity: sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.1':
resolution: {integrity: sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.1':
resolution: {integrity: sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.1':
resolution: {integrity: sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==}
@@ -1652,24 +1683,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.16':
resolution: {integrity: sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.16':
resolution: {integrity: sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.16':
resolution: {integrity: sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.16':
resolution: {integrity: sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==}
@@ -3003,48 +3038,56 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-arm64-gnu@1.32.0:
resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [musl]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [musl]
lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
@@ -3357,6 +3400,12 @@ packages:
nodemailer:
optional: true
+ next-themes@0.4.6:
+ resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
+ peerDependencies:
+ react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
+
next@16.0.10:
resolution: {integrity: sha512-RtWh5PUgI+vxlV3HdR+IfWA1UUHu0+Ram/JBO4vWB54cVPentCD0e+lxyAYEsDTqGGMg7qpjhKh6dc6aW7W/sA==}
engines: {node: '>=20.9.0'}
@@ -7654,6 +7703,11 @@ snapshots:
next: 16.0.10(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react: 19.2.0
+ next-themes@0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
+ dependencies:
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+
next@16.0.10(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@next/env': 16.0.10
diff --git a/src/app/error.tsx b/src/app/error.tsx
index 6ac8bcf..f9682b8 100644
--- a/src/app/error.tsx
+++ b/src/app/error.tsx
@@ -30,8 +30,10 @@ export default function Error({
into it.
{error.message && (
-
-
{error.message}
+
)}
`
+ (set by next-themes), not via prefers-color-scheme. Most of the UI flips
+ purely through the CSS variables redefined under `.dark` below; `dark:`
+ utilities are only needed for the handful of hardcoded status colors. */
+@custom-variant dark (&:where(.dark, .dark *));
+
@supports (color: color(display-p3 1 1 1)) {
:root {
/* Neutral paper base */
@@ -65,6 +71,53 @@
}
}
+/* Dark palette. Because every color utility resolves through these variables
+ (see `@theme inline` below), redefining them under `.dark` flips the whole
+ UI without touching components. The grayscale is *inverted by role*, not by
+ name: --gray-5 stays the "barely off the background" tint (hover/code bg),
+ --gray-90 stays the strongest text — so existing class usage keeps meaning. */
+.dark {
+ color-scheme: dark;
+
+ --background: #0e0e11;
+ --foreground: #ececef;
+ --surface: #17171b;
+
+ --cyan: #6fb3c9;
+ --cyan-light: #16282e;
+ --magenta: #d96f93;
+ --magenta-light: #2b1721;
+ --yellow: #e8c547;
+ --yellow-medium: #322e15;
+ --yellow-light: #2a2710;
+ --key-black: #ececef;
+
+ --gray-5: #161619;
+ --gray-10: #1d1d22;
+ --gray-20: #2a2a31;
+ --gray-30: #3a3a43;
+ --gray-40: #55555f;
+ --gray-50: #8b8b95;
+ --gray-70: #b4b4bd;
+ --gray-90: #e3e3e8;
+
+ --border-color: #3a3a43;
+ --border-light: #2a2a31;
+}
+
+/* Wider-gamut accents on P3 displays, mirroring the light palette's split. */
+@supports (color: color(display-p3 1 1 1)) {
+ .dark {
+ --cyan: color(display-p3 0.435 0.702 0.788);
+ --cyan-light: color(display-p3 0.086 0.157 0.18);
+ --magenta: color(display-p3 0.851 0.435 0.576);
+ --magenta-light: color(display-p3 0.169 0.09 0.129);
+ --yellow: color(display-p3 0.91 0.773 0.278);
+ --yellow-medium: color(display-p3 0.196 0.18 0.082);
+ --yellow-light: color(display-p3 0.165 0.153 0.063);
+ }
+}
+
@theme {
/* Registering the animation in `@theme` is what makes Tailwind 4 emit a
real `animate-indeterminate-bar` utility. A bare `.animate-*` class in
@@ -225,6 +278,79 @@ pre code.hljs {
padding: 0 0.25rem;
}
+/* Dark syntax tokens (highlight.js github-dark palette). The imported
+ github.css is light-only and an @import can't be scoped, so the token
+ colors are overridden under `.dark`. Code-block background comes from
+ `pre { background: var(--gray-5) }`, which already flips with the theme. */
+.dark .hljs {
+ color: #c9d1d9;
+}
+.dark .hljs-doctag,
+.dark .hljs-keyword,
+.dark .hljs-meta .hljs-keyword,
+.dark .hljs-template-tag,
+.dark .hljs-template-variable,
+.dark .hljs-type,
+.dark .hljs-variable.language_ {
+ color: #ff7b72;
+}
+.dark .hljs-title,
+.dark .hljs-title.class_,
+.dark .hljs-title.class_.inherited__,
+.dark .hljs-title.function_ {
+ color: #d2a8ff;
+}
+.dark .hljs-attr,
+.dark .hljs-attribute,
+.dark .hljs-literal,
+.dark .hljs-meta,
+.dark .hljs-number,
+.dark .hljs-operator,
+.dark .hljs-variable,
+.dark .hljs-selector-attr,
+.dark .hljs-selector-class,
+.dark .hljs-selector-id {
+ color: #79c0ff;
+}
+.dark .hljs-regexp,
+.dark .hljs-string,
+.dark .hljs-meta .hljs-string {
+ color: #a5d6ff;
+}
+.dark .hljs-built_in,
+.dark .hljs-symbol {
+ color: #ffa657;
+}
+.dark .hljs-comment,
+.dark .hljs-code,
+.dark .hljs-formula {
+ color: #8b949e;
+}
+.dark .hljs-name,
+.dark .hljs-quote,
+.dark .hljs-selector-tag,
+.dark .hljs-selector-pseudo {
+ color: #7ee787;
+}
+.dark .hljs-subst {
+ color: #c9d1d9;
+}
+.dark .hljs-section {
+ color: #1f6feb;
+ font-weight: bold;
+}
+.dark .hljs-bullet {
+ color: #f2cc60;
+}
+.dark .hljs-addition {
+ color: #aff5b4;
+ background-color: #033a16;
+}
+.dark .hljs-deletion {
+ color: #ffdcd7;
+ background-color: #67060c;
+}
+
/* Override classes for values previously in style attributes – must win over Tailwind !important */
.comment-line-badge {
color: var(--magenta) !important;
@@ -297,6 +423,16 @@ pre code.hljs {
}
}
+/* @uiw/react-codemirror paints its base theme's background on the editor;
+ force it transparent so the Write tab sits flush on the card surface
+ (bg-surface) in both light and dark, rather than showing CodeMirror's own
+ light panel color (which looked broken on the dark theme). */
+.rfc-markdown-editor .cm-editor,
+.rfc-markdown-editor .cm-scroller,
+.rfc-markdown-editor .cm-gutters {
+ background-color: transparent !important;
+}
+
@media (min-width: 640px) {
.rfc-markdown-editor .cm-content {
padding: 1.5rem;
@@ -320,3 +456,12 @@ pre code.hljs {
margin: 0 1px;
white-space: pre-wrap;
}
+
+.dark .cm-diff-added {
+ background-color: rgba(46, 160, 67, 0.25);
+}
+.dark .cm-diff-removed {
+ background-color: rgba(248, 81, 73, 0.2);
+ color: rgb(255 179 179);
+ text-decoration-color: rgba(248, 81, 73, 0.7);
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 9818759..a62271b 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -3,6 +3,7 @@ import { Geist } from "next/font/google";
import localFont from "next/font/local";
import { TooltipProvider } from "@/components/Tooltip";
import { PostHogProvider } from "@/providers/PostHogProvider";
+import { ThemeProvider } from "@/providers/ThemeProvider";
import "./globals.css";
const geist = Geist({
@@ -48,11 +49,13 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
-
+
-
- {children}
-
+
+
+ {children}
+
+
);
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
index 401ea80..9242921 100644
--- a/src/components/Footer.tsx
+++ b/src/components/Footer.tsx
@@ -1,4 +1,5 @@
import Link from "next/link";
+import ThemeToggle from "@/components/ThemeToggle";
const SECTIONS = [
{
@@ -71,6 +72,9 @@ export default function Footer() {
))}
+
+
+
);
}
diff --git a/src/components/HourSelect.tsx b/src/components/HourSelect.tsx
index 6e5c61a..c161647 100644
--- a/src/components/HourSelect.tsx
+++ b/src/components/HourSelect.tsx
@@ -27,7 +27,7 @@ export default function HourSelect({
{HOURS.map((h) => (
diff --git a/src/components/MermaidDiagram.tsx b/src/components/MermaidDiagram.tsx
index 17a3b08..3a20e9c 100644
--- a/src/components/MermaidDiagram.tsx
+++ b/src/components/MermaidDiagram.tsx
@@ -1,9 +1,8 @@
"use client";
+import { useTheme } from "next-themes";
import { useEffect, useRef, useState } from "react";
-let mermaidInitialized = false;
-
interface MermaidDiagramProps {
chart: string;
}
@@ -12,6 +11,7 @@ export function MermaidDiagram({ chart }: MermaidDiagramProps) {
const containerRef = useRef
(null);
const [svg, setSvg] = useState(null);
const [error, setError] = useState(null);
+ const { resolvedTheme } = useTheme();
useEffect(() => {
let cancelled = false;
@@ -19,14 +19,12 @@ export function MermaidDiagram({ chart }: MermaidDiagramProps) {
async function render() {
const mermaid = (await import("mermaid")).default;
- if (!mermaidInitialized) {
- mermaid.initialize({
- startOnLoad: false,
- theme: "neutral",
- fontFamily: "inherit",
- });
- mermaidInitialized = true;
- }
+ // Re-initialize each render so a theme switch re-themes the diagram.
+ mermaid.initialize({
+ startOnLoad: false,
+ theme: resolvedTheme === "dark" ? "dark" : "neutral",
+ fontFamily: "inherit",
+ });
try {
const id = `mermaid-${Math.random().toString(36).slice(2, 9)}`;
@@ -49,13 +47,15 @@ export function MermaidDiagram({ chart }: MermaidDiagramProps) {
return () => {
cancelled = true;
};
- }, [chart]);
+ }, [chart, resolvedTheme]);
if (error) {
return (
-
-
Mermaid rendering error: {error}
-
+
+
+ Mermaid rendering error: {error}
+
+
{chart}
diff --git a/src/components/RFCMarkdownEditor.tsx b/src/components/RFCMarkdownEditor.tsx
index aba8d03..1957c89 100644
--- a/src/components/RFCMarkdownEditor.tsx
+++ b/src/components/RFCMarkdownEditor.tsx
@@ -5,6 +5,7 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { EditorView } from "@codemirror/view";
import { tags } from "@lezer/highlight";
import CodeMirror, { type ReactCodeMirrorRef } from "@uiw/react-codemirror";
+import { useTheme } from "next-themes";
import { type Ref, useMemo } from "react";
import { diffHighlight } from "@/lib/codemirror-diff";
import { markdownLinkClicks } from "@/lib/codemirror-markdown-links";
@@ -48,6 +49,10 @@ export function RFCMarkdownEditor({
diffAgainst,
wordWrap = true,
}: RFCMarkdownEditorProps) {
+ // Drives CodeMirror's base theme. Without it, @uiw defaults to "light" (a
+ // white surface), which looks broken in dark mode. The transparent-background
+ // override in globals.css then lets the editor sit flush on the card.
+ const { resolvedTheme } = useTheme();
const extensions = useMemo(() => {
const base = [
markdown({ base: markdownLanguage }),
@@ -117,6 +122,7 @@ export function RFCMarkdownEditor({
onChange={onChange}
extensions={extensions}
className={className}
+ theme={resolvedTheme === "dark" ? "dark" : "light"}
height="auto"
basicSetup={{
lineNumbers: true,
diff --git a/src/components/RFCsTopBar.tsx b/src/components/RFCsTopBar.tsx
index a427836..1ab67c8 100644
--- a/src/components/RFCsTopBar.tsx
+++ b/src/components/RFCsTopBar.tsx
@@ -1,6 +1,7 @@
import Link from "next/link";
import type { ReactNode } from "react";
import AccountDropdown from "@/components/AccountDropdown";
+import ThemeToggle from "@/components/ThemeToggle";
interface RFCsTopBarProps {
user?: { name?: string | null; image?: string | null } | null;
@@ -42,19 +43,18 @@ export default function RFCsTopBar({
{subtitle && {subtitle}
}
- {(secondaryActions || primaryActions || user) && (
-
- {secondaryActions}
- {primaryActions && (
-
{primaryActions}
- )}
- {user && (
-
-
-
- )}
-
- )}
+
+
+ {secondaryActions}
+ {primaryActions && (
+
{primaryActions}
+ )}
+ {user && (
+
+
+
+ )}
+
);
}
diff --git a/src/components/RfcDiffView.tsx b/src/components/RfcDiffView.tsx
index 69837fb..7090e06 100644
--- a/src/components/RfcDiffView.tsx
+++ b/src/components/RfcDiffView.tsx
@@ -51,14 +51,16 @@ export function RfcPrettyDiffView({
key={idx}
className={`relative rounded-sm pl-3 pr-2 py-1 before:content-[''] before:absolute before:left-0 before:top-0 before:bottom-0 before:w-0.5 ${
isAdded
- ? "bg-green-50 before:bg-green-400"
- : "bg-red-50 line-through decoration-red-400/70 [&_*]:decoration-red-400/70 before:bg-red-400"
+ ? "bg-green-50 dark:bg-green-950/40 before:bg-green-400 dark:before:bg-green-500"
+ : "bg-red-50 dark:bg-red-950/40 line-through decoration-red-400/70 [&_*]:decoration-red-400/70 before:bg-red-400 dark:before:bg-red-500"
}`}
>
{isAdded ? "Added" : "Removed"}
@@ -125,15 +127,15 @@ export function RfcMonoDiffView({ entries }: RfcMonoDiffViewProps) {
{rows.map((row, idx) => {
const bg =
row.kind === "added"
- ? "bg-green-50"
+ ? "bg-green-50 dark:bg-green-950/40"
: row.kind === "removed"
- ? "bg-red-50"
+ ? "bg-red-50 dark:bg-red-950/40"
: "";
const accent =
row.kind === "added"
- ? "text-green-700"
+ ? "text-green-700 dark:text-green-400"
: row.kind === "removed"
- ? "text-red-700"
+ ? "text-red-700 dark:text-red-400"
: "text-gray-40";
const symbol =
row.kind === "added" ? "+" : row.kind === "removed" ? "-" : " ";
diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx
new file mode 100644
index 0000000..99a8e7f
--- /dev/null
+++ b/src/components/ThemeToggle.tsx
@@ -0,0 +1,107 @@
+"use client";
+
+import { useTheme } from "next-themes";
+import { useEffect, useState } from "react";
+
+const ICON_CLASS = "h-3.5 w-3.5";
+
+function SunIcon() {
+ return (
+
+ Light
+
+
+
+ );
+}
+
+function MoonIcon() {
+ return (
+
+ Dark
+
+
+ );
+}
+
+function MonitorIcon() {
+ return (
+
+ System
+
+
+
+ );
+}
+
+const OPTIONS = [
+ { value: "light", label: "Light", Icon: SunIcon },
+ { value: "system", label: "System", Icon: MonitorIcon },
+ { value: "dark", label: "Dark", Icon: MoonIcon },
+] as const;
+
+/** Three-way light / system / dark switch. Renders inert (no active pill)
+ * until mounted, since the resolved theme is unknown during SSR – this is
+ * what keeps hydration consistent. */
+export default function ThemeToggle() {
+ const { theme, setTheme } = useTheme();
+ const [mounted, setMounted] = useState(false);
+ useEffect(() => setMounted(true), []);
+
+ return (
+ // biome-ignore lint/a11y/useSemanticElements: a labelled group of theme buttons, not a form fieldset
+
+ {OPTIONS.map(({ value, label, Icon }) => {
+ const active = mounted && theme === value;
+ return (
+ setTheme(value)}
+ className={`flex h-6 w-6 cursor-pointer items-center justify-center rounded transition-colors ${
+ active
+ ? "bg-gray-10 text-foreground"
+ : "text-gray-50 hover:text-foreground"
+ }`}
+ >
+
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/TimezoneSelect.tsx b/src/components/TimezoneSelect.tsx
index 09945e0..882292e 100644
--- a/src/components/TimezoneSelect.tsx
+++ b/src/components/TimezoneSelect.tsx
@@ -109,7 +109,7 @@ export default function TimezoneSelect({
/>
{filtered.length === 0 && (
diff --git a/src/providers/ThemeProvider.tsx b/src/providers/ThemeProvider.tsx
new file mode 100644
index 0000000..5b96666
--- /dev/null
+++ b/src/providers/ThemeProvider.tsx
@@ -0,0 +1,19 @@
+"use client";
+
+import { ThemeProvider as NextThemesProvider } from "next-themes";
+
+/** Wraps next-themes. `attribute="class"` toggles `class="dark"` on ,
+ * which is what the dark palette in globals.css keys off. `defaultTheme`
+ * is "system" so a first-time visitor follows their OS preference. */
+export function ThemeProvider({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
From 8fba6e47bfca7451fc9ed414b28a20561b5b5fbb Mon Sep 17 00:00:00 2001
From: Catalin Irimie
Date: Sat, 27 Jun 2026 00:28:16 +0300
Subject: [PATCH 2/5] chore: clear pre-existing biome errors on theme-touched
files
These two files predate the biome rules that now flag them and were never
re-linted (lint-staged only checks staged files). The theme change re-stages
them, so resolve the errors to keep the branch lint-clean:
- Rename the error-boundary component Error -> ErrorBoundary, which avoids
shadowing the global Error (Next.js does not care about the component name)
- Annotate MermaidDiagram's dangerouslySetInnerHTML: it injects SVG that
mermaid generated from the diagram source, not arbitrary user HTML
---
src/app/error.tsx | 2 +-
src/components/MermaidDiagram.tsx | 1 +
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/app/error.tsx b/src/app/error.tsx
index f9682b8..fa3fc63 100644
--- a/src/app/error.tsx
+++ b/src/app/error.tsx
@@ -3,7 +3,7 @@
import posthog from "posthog-js";
import { useEffect } from "react";
-export default function Error({
+export default function ErrorBoundary({
error,
reset,
}: {
diff --git a/src/components/MermaidDiagram.tsx b/src/components/MermaidDiagram.tsx
index 3a20e9c..77616aa 100644
--- a/src/components/MermaidDiagram.tsx
+++ b/src/components/MermaidDiagram.tsx
@@ -74,6 +74,7 @@ export function MermaidDiagram({ chart }: MermaidDiagramProps) {
);
From f69132e091d20f60f091558ecc8a747797bbf1e0 Mon Sep 17 00:00:00 2001
From: Catalin Irimie
Date: Sat, 27 Jun 2026 00:34:29 +0300
Subject: [PATCH 3/5] fix: guard editor theme behind a mounted check to avoid
hydration mismatch
@uiw/react-codemirror sets a cm-theme-{light,dark} wrapper class from its
theme prop. Reading next-themes' resolvedTheme during render made the server
emit cm-theme-light while a dark-mode client's first render emitted
cm-theme-dark, a hydration mismatch on the RFC editor pages. Gate the dark
theme behind the same mounted guard ThemeToggle uses: render light on the
first pass (matching SSR), then switch after mount.
---
src/components/RFCMarkdownEditor.tsx | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/src/components/RFCMarkdownEditor.tsx b/src/components/RFCMarkdownEditor.tsx
index 1957c89..a9a5ac6 100644
--- a/src/components/RFCMarkdownEditor.tsx
+++ b/src/components/RFCMarkdownEditor.tsx
@@ -6,7 +6,7 @@ import { EditorView } from "@codemirror/view";
import { tags } from "@lezer/highlight";
import CodeMirror, { type ReactCodeMirrorRef } from "@uiw/react-codemirror";
import { useTheme } from "next-themes";
-import { type Ref, useMemo } from "react";
+import { type Ref, useEffect, useMemo, useState } from "react";
import { diffHighlight } from "@/lib/codemirror-diff";
import { markdownLinkClicks } from "@/lib/codemirror-markdown-links";
@@ -52,7 +52,15 @@ export function RFCMarkdownEditor({
// Drives CodeMirror's base theme. Without it, @uiw defaults to "light" (a
// white surface), which looks broken in dark mode. The transparent-background
// override in globals.css then lets the editor sit flush on the card.
+ //
+ // next-themes can't resolve the theme during SSR (resolvedTheme is undefined),
+ // so the server renders light. Match that on the first client render, then
+ // switch after mount — the same guard ThemeToggle uses — to avoid a hydration
+ // mismatch on @uiw's cm-theme-{light,dark} wrapper class for dark-mode users.
const { resolvedTheme } = useTheme();
+ const [mounted, setMounted] = useState(false);
+ useEffect(() => setMounted(true), []);
+ const editorTheme = mounted && resolvedTheme === "dark" ? "dark" : "light";
const extensions = useMemo(() => {
const base = [
markdown({ base: markdownLanguage }),
@@ -122,7 +130,7 @@ export function RFCMarkdownEditor({
onChange={onChange}
extensions={extensions}
className={className}
- theme={resolvedTheme === "dark" ? "dark" : "light"}
+ theme={editorTheme}
height="auto"
basicSetup={{
lineNumbers: true,
From 1655f27f66ef65c991cc2d97a84af2a788d30e2b Mon Sep 17 00:00:00 2001
From: Catalin Irimie
Date: Mon, 29 Jun 2026 01:05:59 +0300
Subject: [PATCH 4/5] fix: keep error-page cyan button text readable in dark
mode
The dark palette brightens --cyan while --foreground goes near-white, so the
error boundary "Try again" button (bg-cyan + text-foreground) dropped to ~2:1
contrast in dark mode. Add dark:text-surface so dark mode draws dark text on
the light cyan; light mode keeps its readable dark-on-cyan.
---
src/app/error.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/app/error.tsx b/src/app/error.tsx
index fa3fc63..a571cb9 100644
--- a/src/app/error.tsx
+++ b/src/app/error.tsx
@@ -39,7 +39,7 @@ export default function ErrorBoundary({
reset()}
- className="w-full rounded-md bg-cyan px-4 py-2 font-medium text-foreground transition-all hover:opacity-80"
+ className="w-full rounded-md bg-cyan px-4 py-2 font-medium text-foreground dark:text-surface transition-all hover:opacity-80"
>
Try again
From 1b794efac3b73b55aee093717ff8ff4100718046 Mon Sep 17 00:00:00 2001
From: Catalin Irimie
Date: Mon, 29 Jun 2026 01:10:24 +0300
Subject: [PATCH 5/5] fix: restore emphasis/strong syntax tokens in dark code
blocks
The dark highlight.js override copied most of github-dark but omitted
.hljs-emphasis and .hljs-strong, so they kept the imported light theme color
(#24292e) and rendered near-invisible on the dark code background. Override
both to the github-dark base text color; italic/bold styling is unchanged.
---
src/app/globals.css | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/src/app/globals.css b/src/app/globals.css
index 9b36a6b..f8c396e 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -335,6 +335,10 @@ pre code.hljs {
.dark .hljs-subst {
color: #c9d1d9;
}
+.dark .hljs-emphasis,
+.dark .hljs-strong {
+ color: #c9d1d9;
+}
.dark .hljs-section {
color: #1f6feb;
font-weight: bold;