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..a571cb9 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, }: { @@ -30,14 +30,16 @@ export default function Error({ into it.

{error.message && ( -
-

{error.message}

+
+

+ {error.message} +

)} diff --git a/src/app/globals.css b/src/app/globals.css index 321f1c9..f8c396e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,6 +1,12 @@ @import "tailwindcss" important; @import "highlight.js/styles/github.css"; +/* Class-based dark mode: `dark:` utilities apply under `` + (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,83 @@ 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-emphasis, +.dark .hljs-strong { + 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 +427,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 +460,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..77616aa 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}
         
@@ -74,6 +74,7 @@ export function MermaidDiagram({ chart }: MermaidDiagramProps) {
); diff --git a/src/components/RFCMarkdownEditor.tsx b/src/components/RFCMarkdownEditor.tsx index aba8d03..a9a5ac6 100644 --- a/src/components/RFCMarkdownEditor.tsx +++ b/src/components/RFCMarkdownEditor.tsx @@ -5,7 +5,8 @@ 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 { type Ref, useMemo } from "react"; +import { useTheme } from "next-themes"; +import { type Ref, useEffect, useMemo, useState } from "react"; import { diffHighlight } from "@/lib/codemirror-diff"; import { markdownLinkClicks } from "@/lib/codemirror-markdown-links"; @@ -48,6 +49,18 @@ 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. + // + // 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 }), @@ -117,6 +130,7 @@ export function RFCMarkdownEditor({ onChange={onChange} extensions={extensions} className={className} + theme={editorTheme} 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} + + ); +}