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}

+
+

+ {error.message} +

)}
))} +
+ +
); } 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 ( + + ); + })} +
+ ); +} 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({ 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;