From 557bf16acb6939f984de68b65fec8526ddd81ec9 Mon Sep 17 00:00:00 2001 From: Khiet Tam Nguyen Date: Thu, 29 Oct 2026 20:35:21 +1100 Subject: [PATCH 1/5] feat: add system-aware dark mode selector --- docusaurus.config.js | 11 +-- package-lock.json | 28 +----- src/README.md | 2 +- src/components/Homepage.module.css | 6 +- src/components/documentation/ApiEndpoint.js | 14 +-- .../documentation/ApiEndpoint.module.css | 2 +- src/css/custom.css | 97 +++++++++++++++++-- src/theme/Navbar/ColorModeToggle/index.js | 26 +++++ .../Navbar/ColorModeToggle/styles.module.css | 32 ++++++ 9 files changed, 161 insertions(+), 57 deletions(-) create mode 100644 src/theme/Navbar/ColorModeToggle/index.js create mode 100644 src/theme/Navbar/ColorModeToggle/styles.module.css diff --git a/docusaurus.config.js b/docusaurus.config.js index bb4b8c15..9a324769 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -209,16 +209,9 @@ module.exports = { additionalLanguages: ["bash", "docker", "json"], }, colorMode: { - // "light" | "dark" defaultMode: "light", - - // Hides the switch in the navbar - // Useful if you want to support a single color mode - disableSwitch: true, - - // Should we use the prefers-color-scheme media-query, - // using user system preferences, instead of the hardcoded defaultMode - respectPrefersColorScheme: false, + disableSwitch: false, + respectPrefersColorScheme: true, }, }, presets: [ diff --git a/package-lock.json b/package-lock.json index ea1fa34f..6855b083 100644 --- a/package-lock.json +++ b/package-lock.json @@ -179,7 +179,6 @@ "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.56.0.tgz", "integrity": "sha512-+mKUdYvqOi0BcvpAEyCEw49vSBptufIcfibtHz2bdr1pI789M46Yt0uQEk/sxtK3teh71OQvVFHaTDzShUWewQ==", "license": "MIT", - "peer": true, "dependencies": { "@algolia/client-common": "5.56.0", "@algolia/requester-browser-xhr": "5.56.0", @@ -305,7 +304,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2083,7 +2081,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -2106,7 +2103,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2216,7 +2212,6 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz", "integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==", "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -2638,7 +2633,6 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz", "integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==", "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -3673,7 +3667,6 @@ "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.10.2.tgz", "integrity": "sha512-Sqwl4FPoZBDrlY8I2VU2H8O0M91CHp9T8ToMSkTZmjvHCif+1laqfXi6sTk8IfyVS/trN5yNjcWd1bFsGB6W5Q==", "license": "MIT", - "peer": true, "dependencies": { "@docusaurus/core": "3.10.2", "@docusaurus/logger": "3.10.2", @@ -4639,7 +4632,6 @@ "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", "license": "MIT", - "peer": true, "dependencies": { "@types/mdx": "^2.0.0" }, @@ -4943,7 +4935,6 @@ "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -5482,7 +5473,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.5.tgz", "integrity": "sha512-keKxkZMqnDicuvFoJbzrhbtdLSPhj/rZThDlKWCDbgXmUg0rEUFtRssDXKYmtXluZlIqiC5VqkCgRwzuyLHKHw==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -5824,7 +5814,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5892,7 +5881,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5938,7 +5926,6 @@ "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.56.0.tgz", "integrity": "sha512-PrqppUmhT4ENdas2pH9caE7efUcxy6EcSFhWzosiVuQBzu2tQ5yLTI6jwomT/1cuBnivzGfxiJCqDNN9FRRh+Q==", "license": "MIT", - "peer": true, "dependencies": { "@algolia/abtesting": "1.22.0", "@algolia/client-abtesting": "5.56.0", @@ -6591,7 +6578,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", @@ -7693,7 +7679,6 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz", "integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==", "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -9242,7 +9227,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -14187,7 +14171,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -14777,7 +14760,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", @@ -15681,7 +15663,6 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz", "integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==", "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -16657,7 +16638,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -16670,7 +16650,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -16727,7 +16706,6 @@ "resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-6.0.0.tgz", "integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/react": "*" }, @@ -16756,7 +16734,6 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -19029,8 +19006,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tunnel-agent": { "version": "0.6.0", @@ -19530,7 +19506,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -19744,7 +19719,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz", "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", diff --git a/src/README.md b/src/README.md index c0118349..84960860 100644 --- a/src/README.md +++ b/src/README.md @@ -52,7 +52,7 @@ HTTP method badges: tinted background with dark text (WCAG AA), e.g. POST `rgba( - Max content width: 840px (`.theme-doc-markdown`) - Container width: 1366px - Navbar height: 4rem -- Light mode only (dark mode switch disabled) +- Theme selector in the navbar: System (default), Light, or Dark. System follows the OS setting ## Components diff --git a/src/components/Homepage.module.css b/src/components/Homepage.module.css index f19608bf..f916345b 100644 --- a/src/components/Homepage.module.css +++ b/src/components/Homepage.module.css @@ -318,7 +318,7 @@ font-size: clamp(2.25rem, 4vw, 2.9rem); font-weight: 600; letter-spacing: -0.005em; - color: var(--ifm-background-color); + color: var(--on-ink-plate); margin-bottom: 0.75rem; } @@ -399,13 +399,13 @@ .finalCta .btnSecondary { background: transparent; border-color: rgba(253, 252, 250, 0.25); - color: var(--ifm-background-color); + color: var(--on-ink-plate); } .finalCta .btnSecondary:hover { background: rgba(253, 252, 250, 0.08); border-color: rgba(253, 252, 250, 0.5); - color: var(--ifm-background-color); + color: var(--on-ink-plate); } /* The GitHub mark ships as a dark octocat that disappears on ink. diff --git a/src/components/documentation/ApiEndpoint.js b/src/components/documentation/ApiEndpoint.js index c623b423..91ee2f32 100644 --- a/src/components/documentation/ApiEndpoint.js +++ b/src/components/documentation/ApiEndpoint.js @@ -22,19 +22,19 @@ function renderValues(text) { const MethodBadge = ({ method = "" }) => { const colors = { - GET: { bg: "rgba(97, 175, 254, 0.18)", fg: "#0060a1" }, - POST: { bg: "rgba(73, 204, 144, 0.16)", fg: "#047857" }, - PUT: { bg: "rgba(252, 161, 48, 0.18)", fg: "#92400e" }, - DELETE: { bg: "rgba(249, 62, 62, 0.12)", fg: "#b91c1c" }, - PATCH: { bg: "rgba(80, 227, 194, 0.18)", fg: "#0f766e" }, - HEAD: { bg: "rgba(144, 97, 249, 0.14)", fg: "#6d28d9" }, + GET: { bg: "rgba(97, 175, 254, 0.18)", fg: "var(--method-get-text)" }, + POST: { bg: "rgba(73, 204, 144, 0.16)", fg: "var(--method-post-text)" }, + PUT: { bg: "rgba(252, 161, 48, 0.18)", fg: "var(--method-put-text)" }, + DELETE: { bg: "rgba(249, 62, 62, 0.12)", fg: "var(--method-delete-text)" }, + PATCH: { bg: "rgba(80, 227, 194, 0.18)", fg: "var(--method-patch-text)" }, + HEAD: { bg: "rgba(144, 97, 249, 0.14)", fg: "var(--method-head-text)" }, }; const isMultipart = !method; const safeMethod = isMultipart ? "multipart/form-data" : method.toUpperCase(); const color = colors[safeMethod] || { bg: "rgba(100, 116, 139, 0.14)", - fg: "#475569", + fg: "var(--method-default-text)", }; return ( diff --git a/src/components/documentation/ApiEndpoint.module.css b/src/components/documentation/ApiEndpoint.module.css index 13480e70..bfe48f02 100644 --- a/src/components/documentation/ApiEndpoint.module.css +++ b/src/components/documentation/ApiEndpoint.module.css @@ -184,7 +184,7 @@ .paramDefault code { font-size: 0.75rem; - background-color: rgba(241, 245, 249, 0.8); + background-color: var(--ifm-color-emphasis-100); color: var(--ifm-color-primary-dark); } diff --git a/src/css/custom.css b/src/css/custom.css index 70e3efb8..6d846f76 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -23,6 +23,10 @@ --ifm-border-color: #e6e2da; --paper-shade: #f6f5f1; --ink-plate: #1c1917; + --on-ink-plate: #fdfcfa; + --announcement-bg: var(--ifm-color-primary); + --table-row-border: rgba(0, 0, 0, 0.04); + --link-underline-color: rgba(14, 123, 163, 0.3); --font-serif: "Newsreader Variable", Georgia, "Times New Roman", serif; --ifm-font-family-base: system-ui, -apple-system, "Segoe UI", Roboto, @@ -61,6 +65,46 @@ --code-string: #fbbf24; --code-url: #6ee7b7; --code-punct: #94a3b8; + + /* HTTP method labels are reused by sidebar badges and endpoint examples. */ + --method-get-text: #0060a1; + --method-post-text: #047857; + --method-put-text: #92400e; + --method-delete-text: #b91c1c; + --method-patch-text: #0f766e; + --method-head-text: #6d28d9; + --method-default-text: #475569; +} + +html[data-theme="dark"] { + --ifm-color-primary: #82cdee; + --ifm-color-primary-dark: #63bbdf; + --ifm-color-primary-darker: #4caccf; + --ifm-color-primary-darkest: #3e96ba; + --ifm-color-primary-light: #a5dffa; + --ifm-color-primary-lighter: #c1ebff; + --ifm-color-primary-lightest: #daf4ff; + + --ifm-background-color: #171b1e; + --ifm-background-surface-color: #202529; + --ifm-border-color: #3b4349; + --paper-shade: #252b30; + --ifm-color-content: #dfdcd5; + --ifm-color-content-secondary: #b9b5ae; + --ifm-heading-color: #f5f2e9; + --ifm-navbar-background-color: #1b2024; + --rubric: #f58c7c; + --announcement-bg: #125e7b; + --table-row-border: rgba(255, 255, 255, 0.08); + --link-underline-color: rgba(130, 205, 238, 0.45); + + --method-get-text: #7dd3fc; + --method-post-text: #6ee7b7; + --method-put-text: #fcd34d; + --method-delete-text: #fca5a5; + --method-patch-text: #5eead4; + --method-head-text: #c4b5fd; + --method-default-text: #cbd5e1; } /* --- ACCESSIBILITY --- */ @@ -317,7 +361,7 @@ h4:hover > .hash-link, text-decoration: underline; text-decoration-thickness: 1.5px; text-underline-offset: 3px; - text-decoration-color: rgba(14, 123, 163, 0.3); + text-decoration-color: var(--link-underline-color); transition: text-decoration-color 0.2s ease, color 0.2s ease; font-weight: 500; } @@ -373,7 +417,7 @@ h4:hover > .hash-link, .markdown table td { padding: 10px 16px; border: none; - border-bottom: 1px solid rgba(0, 0, 0, 0.04); + border-bottom: 1px solid var(--table-row-border); color: var(--ifm-color-content); line-height: 1.6; font-variant-numeric: tabular-nums; @@ -509,9 +553,44 @@ h4:hover > .hash-link, text-decoration-color: rgba(239, 68, 68, 0.5); } +/* Alert links and headings need lighter foregrounds on dark surfaces. */ +[data-theme="dark"] .alert--info .alert__heading, +[data-theme="dark"] .markdown .alert--info a { + color: var(--ifm-color-primary-light); +} + +[data-theme="dark"] .alert--success .alert__heading, +[data-theme="dark"] .markdown .alert--success a { + color: #6ee7b7; +} + +[data-theme="dark"] .alert--warning .alert__heading, +[data-theme="dark"] .markdown .alert--warning a { + color: #fcd34d; +} + +[data-theme="dark"] .alert--danger .alert__heading, +[data-theme="dark"] .markdown .alert--danger a { + color: #fca5a5; +} + +[data-theme="dark"] .alert table { + background-color: rgba(255, 255, 255, 0.04); + border-color: rgba(255, 255, 255, 0.12); +} + +[data-theme="dark"] .alert table th { + background-color: rgba(255, 255, 255, 0.06); + border-bottom-color: var(--ifm-border-color); +} + +[data-theme="dark"] .alert table td { + border-bottom-color: var(--table-row-border); +} + /* --- ANNOUNCEMENT BAR --- */ div[class*="announcementBar_"] { - background: var(--ifm-color-primary); + background: var(--announcement-bg); color: white; font-weight: 600; border: none; @@ -700,31 +779,31 @@ a[class*="sidebarLogo"] b { .sidebar-method-post .menu__link::before { content: "POST"; background-color: rgba(73, 204, 144, 0.16); - color: #047857; + color: var(--method-post-text); } .sidebar-method-get .menu__link::before { content: "GET"; background-color: rgba(97, 175, 254, 0.18); - color: #0060a1; + color: var(--method-get-text); } .sidebar-method-put .menu__link::before { content: "PUT"; background-color: rgba(252, 161, 48, 0.18); - color: #92400e; + color: var(--method-put-text); } .sidebar-method-delete .menu__link::before { content: "DEL"; background-color: rgba(249, 62, 62, 0.12); - color: #b91c1c; + color: var(--method-delete-text); } .sidebar-method-head .menu__link::before { content: "HEAD"; background-color: rgba(144, 97, 249, 0.14); - color: #6d28d9; + color: var(--method-head-text); } /* --- DOC FOOTER --- */ @@ -788,7 +867,7 @@ a[class*="sidebarLogo"] b { /* --- FOOTER --- */ .footer--dark { - --ifm-footer-background-color: var(--ifm-heading-color); + --ifm-footer-background-color: var(--ink-plate); --ifm-footer-padding-vertical: 5.5rem; /* On the dark footer the link hover must lighten, not resolve to the dark ink-blue primary (which would vanish). Use the light end of the ramp. */ diff --git a/src/theme/Navbar/ColorModeToggle/index.js b/src/theme/Navbar/ColorModeToggle/index.js new file mode 100644 index 00000000..8471c3dc --- /dev/null +++ b/src/theme/Navbar/ColorModeToggle/index.js @@ -0,0 +1,26 @@ +import { useColorMode } from "@docusaurus/theme-common"; +import clsx from "clsx"; +import styles from "./styles.module.css"; + +export default function NavbarColorModeToggle({ className }) { + const { colorModeChoice, setColorMode } = useColorMode(); + + return ( +
+ +
+ ); +} diff --git a/src/theme/Navbar/ColorModeToggle/styles.module.css b/src/theme/Navbar/ColorModeToggle/styles.module.css new file mode 100644 index 00000000..2712387a --- /dev/null +++ b/src/theme/Navbar/ColorModeToggle/styles.module.css @@ -0,0 +1,32 @@ +.container { + display: inline-flex; + align-items: center; +} + +/* Docusaurus renders the color mode control after navbar items. Place it before + the explicitly configured search item without changing the search layout. */ +:global(.navbar__items--right) > .container { + order: -1; +} + +.select { + font-family: var(--ifm-font-family-base); + font-size: 0.8rem; + font-weight: 600; + color: var(--ifm-color-content-secondary); + background: var(--ifm-navbar-background-color); + border: 1px solid var(--ifm-border-color); + border-radius: 4px; + padding: 0.4rem 0.5rem; + cursor: pointer; +} + +.select:hover { + color: var(--ifm-heading-color); + border-color: var(--ifm-color-primary); +} + +.select:focus-visible { + outline: 2px solid var(--ifm-color-primary); + outline-offset: 2px; +} From 38e9620fbbc8054e332d7ee770f3db74f9b31794 Mon Sep 17 00:00:00 2001 From: Khiet Tam Nguyen Date: Thu, 29 Oct 2026 21:24:11 +1100 Subject: [PATCH 2/5] feat: add icons to theme selector dropdown --- src/theme/Navbar/ColorModeToggle/index.js | 177 ++++++++++++++++-- .../Navbar/ColorModeToggle/styles.module.css | 97 +++++++++- 2 files changed, 255 insertions(+), 19 deletions(-) diff --git a/src/theme/Navbar/ColorModeToggle/index.js b/src/theme/Navbar/ColorModeToggle/index.js index 8471c3dc..3ad2f15d 100644 --- a/src/theme/Navbar/ColorModeToggle/index.js +++ b/src/theme/Navbar/ColorModeToggle/index.js @@ -1,26 +1,177 @@ import { useColorMode } from "@docusaurus/theme-common"; import clsx from "clsx"; +import { useEffect, useId, useRef, useState } from "react"; import styles from "./styles.module.css"; +const MODES = [ + { value: "system", label: "System" }, + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, +]; + +function Icon({ name, className }) { + return ( + + ); +} + export default function NavbarColorModeToggle({ className }) { const { colorModeChoice, setColorMode } = useColorMode(); + const selectedMode = colorModeChoice ?? "system"; + const selectedOption = + MODES.find(({ value }) => value === selectedMode) ?? MODES[0]; + const [isOpen, setIsOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + const containerRef = useRef(null); + const triggerRef = useRef(null); + const optionRefs = useRef([]); + const menuId = useId(); + + useEffect(() => { + if (isOpen) optionRefs.current[activeIndex]?.focus(); + }, [isOpen, activeIndex]); + + useEffect(() => { + if (!isOpen) return undefined; + + const closeOutside = (event) => { + if (!containerRef.current?.contains(event.target)) setIsOpen(false); + }; + + document.addEventListener("pointerdown", closeOutside); + document.addEventListener("focusin", closeOutside); + return () => { + document.removeEventListener("pointerdown", closeOutside); + document.removeEventListener("focusin", closeOutside); + }; + }, [isOpen]); + + const openMenu = () => { + setActiveIndex(MODES.indexOf(selectedOption)); + setIsOpen(true); + }; + + const closeMenu = () => { + setIsOpen(false); + triggerRef.current?.focus(); + }; + + const selectMode = (mode) => { + setColorMode(mode === "system" ? null : mode); + closeMenu(); + }; + + const handleOptionKeyDown = (event, index) => { + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + setActiveIndex((index + 1) % MODES.length); + break; + case "ArrowUp": + event.preventDefault(); + setActiveIndex((index + MODES.length - 1) % MODES.length); + break; + case "Home": + event.preventDefault(); + setActiveIndex(0); + break; + case "End": + event.preventDefault(); + setActiveIndex(MODES.length - 1); + break; + case "Escape": + event.preventDefault(); + closeMenu(); + break; + default: + break; + } + }; return ( -
- + {MODES.map(({ value, label }, index) => { + const selected = value === selectedMode; + return ( + + ); + })} +
); } diff --git a/src/theme/Navbar/ColorModeToggle/styles.module.css b/src/theme/Navbar/ColorModeToggle/styles.module.css index 2712387a..a6d2f459 100644 --- a/src/theme/Navbar/ColorModeToggle/styles.module.css +++ b/src/theme/Navbar/ColorModeToggle/styles.module.css @@ -1,32 +1,117 @@ .container { + position: relative; display: inline-flex; align-items: center; } -/* Docusaurus renders the color mode control after navbar items. Place it before - the explicitly configured search item without changing the search layout. */ +/* The theme control is rendered after the right navbar items by Docusaurus. */ :global(.navbar__items--right) > .container { order: -1; } -.select { +.trigger { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + min-height: 2.25rem; + padding: 0.375rem 0.625rem; font-family: var(--ifm-font-family-base); font-size: 0.8rem; font-weight: 600; + line-height: 1; color: var(--ifm-color-content-secondary); background: var(--ifm-navbar-background-color); border: 1px solid var(--ifm-border-color); border-radius: 4px; - padding: 0.4rem 0.5rem; cursor: pointer; } -.select:hover { +.trigger:hover, +.trigger[aria-expanded="true"] { color: var(--ifm-heading-color); + background: var(--paper-shade); border-color: var(--ifm-color-primary); } -.select:focus-visible { +.trigger:focus-visible, +.option:focus-visible { outline: 2px solid var(--ifm-color-primary); outline-offset: 2px; } + +.icon { + flex-shrink: 0; +} + +.chevron { + width: 14px; + height: 14px; + flex-shrink: 0; + opacity: 0.7; +} + +.menu { + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + z-index: 1000; + min-width: 10.5rem; + padding: 0.25rem; + background: var(--ifm-navbar-background-color); + border: 1px solid var(--ifm-border-color); + border-radius: 6px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12); +} + +.menu[hidden] { + display: none; +} + +.option { + display: flex; + align-items: center; + gap: 0.625rem; + width: 100%; + min-height: 2.75rem; + padding: 0.5rem 0.625rem; + font-family: var(--ifm-font-family-base); + font-size: 0.85rem; + font-weight: 500; + line-height: 1.2; + text-align: left; + color: var(--ifm-color-content-secondary); + background: transparent; + border: 0; + border-radius: 4px; + cursor: pointer; +} + +.option:hover, +.option:focus-visible { + color: var(--ifm-heading-color); + background: var(--paper-shade); +} + +.selected { + color: var(--ifm-heading-color); +} + +.check { + width: 16px; + height: 16px; + margin-left: auto; + color: var(--ifm-color-primary); +} + +@media (max-width: 420px) { + .trigger { + min-width: 44px; + min-height: 44px; + padding: 0.5rem; + } + + .triggerLabel { + display: none; + } +} From c397f86d64b48709c03644389270e78da844af4c Mon Sep 17 00:00:00 2001 From: Khiet Tam Nguyen Date: Thu, 29 Oct 2026 21:37:44 +1100 Subject: [PATCH 3/5] fix: improve dark-mode GitHub button contrast and navbar alignment --- src/components/Homepage.module.css | 14 ++++++++++---- src/theme/Navbar/ColorModeToggle/styles.module.css | 9 +++++---- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/components/Homepage.module.css b/src/components/Homepage.module.css index f916345b..ab0abee1 100644 --- a/src/components/Homepage.module.css +++ b/src/components/Homepage.module.css @@ -62,7 +62,8 @@ border: none; } -.btnSecondary { +/* Outrank Infima's `.button.button--secondary` dark text colour. */ +.btnSecondary:global(.button.button--secondary) { display: inline-flex; align-items: center; gap: 0.5rem; @@ -71,7 +72,7 @@ color: var(--ifm-heading-color); } -.btnSecondary:hover { +.btnSecondary:global(.button.button--secondary):hover { background: var(--paper-shade); color: var(--ifm-heading-color); } @@ -396,13 +397,13 @@ /* Secondary button needs an outline-on-dark variant here; the base .btnSecondary assumes a paper background. */ -.finalCta .btnSecondary { +.finalCta .btnSecondary:global(.button.button--secondary) { background: transparent; border-color: rgba(253, 252, 250, 0.25); color: var(--on-ink-plate); } -.finalCta .btnSecondary:hover { +.finalCta .btnSecondary:global(.button.button--secondary):hover { background: rgba(253, 252, 250, 0.08); border-color: rgba(253, 252, 250, 0.5); color: var(--on-ink-plate); @@ -414,6 +415,11 @@ filter: brightness(0) invert(1); } +/* The GitHub SVG has a dark fill, so make it legible on the dark hero. */ +:global(html[data-theme="dark"]) .hero .githubIcon { + filter: brightness(0) invert(1); +} + /* --- TERMINAL --- */ .terminalWindow { diff --git a/src/theme/Navbar/ColorModeToggle/styles.module.css b/src/theme/Navbar/ColorModeToggle/styles.module.css index a6d2f459..90e7107b 100644 --- a/src/theme/Navbar/ColorModeToggle/styles.module.css +++ b/src/theme/Navbar/ColorModeToggle/styles.module.css @@ -14,8 +14,10 @@ align-items: center; justify-content: center; gap: 0.5rem; - min-height: 2.25rem; - padding: 0.375rem 0.625rem; + /* Match Algolia DocSearch's 36px navbar search button. */ + box-sizing: border-box; + height: 36px; + padding: 0 0.625rem; font-family: var(--ifm-font-family-base); font-size: 0.8rem; font-weight: 600; @@ -107,8 +109,7 @@ @media (max-width: 420px) { .trigger { min-width: 44px; - min-height: 44px; - padding: 0.5rem; + padding: 0 0.5rem; } .triggerLabel { From 7e99c58c9753ec9808462e1426a6ba9f2ab3f910 Mon Sep 17 00:00:00 2001 From: Khiet Tam Nguyen Date: Thu, 29 Oct 2026 21:46:53 +1100 Subject: [PATCH 4/5] fix: show theme selector as icon beside mobile search --- .../Navbar/ColorModeToggle/styles.module.css | 43 +++++++++++++++++-- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/src/theme/Navbar/ColorModeToggle/styles.module.css b/src/theme/Navbar/ColorModeToggle/styles.module.css index 90e7107b..7c870258 100644 --- a/src/theme/Navbar/ColorModeToggle/styles.module.css +++ b/src/theme/Navbar/ColorModeToggle/styles.module.css @@ -106,13 +106,48 @@ color: var(--ifm-color-primary); } -@media (max-width: 420px) { +/* Keep a single icon-only theme control beside search in the mobile navbar. */ +@media (max-width: 996px) { + /* Docusaurus normally hides the navbar toggle at this breakpoint. */ + :global(.navbar__items--right) > .container { + display: inline-flex; + margin-right: 0.5rem; + } + + /* Search is absolutely positioned by Docusaurus on mobile. Restore flex + layout so the two controls remain adjacent at every viewport width. */ + :global(.navbar__items--right) > :global([class*="navbarSearchContainer"]) { + position: static; + } + + /* DocSearch switches to an icon below 768px by default; use the same + icon-only layout at Docusaurus's wider mobile navbar breakpoint. */ + :global(.navbar__items--right) :global(.DocSearch-Button) { + box-sizing: border-box; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + border-radius: 4px; + } + + :global(.navbar__items--right) :global(.DocSearch-Button-Placeholder), + :global(.navbar__items--right) :global(.DocSearch-Button-Keys) { + display: none; + } + + /* The mobile sidebar should not show a second theme control. */ + :global(.navbar-sidebar__brand) > .container { + display: none; + } + .trigger { - min-width: 44px; - padding: 0 0.5rem; + width: 36px; + padding: 0; } - .triggerLabel { + .triggerLabel, + .chevron { display: none; } } From 1e9c418445c72bddb8f0bc3edca25df13e45ba64 Mon Sep 17 00:00:00 2001 From: Khiet Tam Nguyen Date: Thu, 29 Oct 2026 21:56:55 +1100 Subject: [PATCH 5/5] fix: prevent sidebar clipping when ads are unavailable --- src/theme/DocSidebar/index.js | 41 +++++++++++++++++--------- src/theme/DocSidebar/styles.module.css | 28 ++++-------------- 2 files changed, 33 insertions(+), 36 deletions(-) diff --git a/src/theme/DocSidebar/index.js b/src/theme/DocSidebar/index.js index 95fcdbd1..c3bbe46a 100644 --- a/src/theme/DocSidebar/index.js +++ b/src/theme/DocSidebar/index.js @@ -1,5 +1,5 @@ -import OriginalDocSidebar from "@theme-original/DocSidebar"; import { useWindowSize } from "@docusaurus/theme-common"; +import OriginalDocSidebar from "@theme-original/DocSidebar"; import React from "react"; import { useLocation } from "react-router"; import styles from "./styles.module.css"; @@ -7,7 +7,9 @@ import styles from "./styles.module.css"; function buildScript(src, attrs = {}) { const script = document.createElement("script"); script.async = true; - Object.keys(attrs).forEach((attr) => script.setAttribute(attr, attrs[attr])); + for (const [attr, value] of Object.entries(attrs)) { + script.setAttribute(attr, value); + } script.src = src; return script; @@ -18,6 +20,7 @@ export default function DocSidebar(props) { const windowSize = useWindowSize(); const { pathname } = useLocation(); + // biome-ignore lint/correctness/useExhaustiveDependencies: Load a new ad when the documentation route changes. React.useEffect(() => { if (windowSize !== "desktop") { return; @@ -29,35 +32,45 @@ export default function DocSidebar(props) { id: "_carbonads_js", } ); - const sidebarWrapper = ref.current; - sidebarWrapper.classList.remove(styles.roomForCarbon); + const sidebar = ref.current?.firstElementChild; + if (!sidebar) { + return; + } const carbonWrapper = document.createElement("div"); carbonWrapper.classList.add(styles.carbonWrapper); + // Keep the sidebar fully scrollable until Carbon actually renders an ad. + // Otherwise blocked ad requests leave an empty 130px gap below the menu. + const observer = new MutationObserver(() => { + if (carbonWrapper.querySelector("#carbonads")) { + carbonWrapper.classList.add(styles.carbonLoaded); + observer.disconnect(); + } + }); + observer.observe(carbonWrapper, { childList: true, subtree: true }); + carbonWrapper.appendChild(script); - sidebarWrapper.firstChild.insertBefore(carbonWrapper, null); + sidebar.appendChild(carbonWrapper); - // If Carbon doesn't serve an ad, reclaim the space. + // Remove the unused wrapper if Carbon does not serve an ad. const checkTimer = setTimeout(() => { if (!carbonWrapper.querySelector("#carbonads")) { - sidebarWrapper.classList.add(styles.roomForCarbon); - carbonWrapper.parentElement.removeChild(carbonWrapper); + observer.disconnect(); + carbonWrapper.remove(); } }, 3000); return () => { clearTimeout(checkTimer); - sidebarWrapper.classList.add(styles.roomForCarbon); - if (carbonWrapper.parentElement) { - carbonWrapper.parentElement.removeChild(carbonWrapper); - } + observer.disconnect(); + carbonWrapper.remove(); }; }, [pathname, windowSize]); - // use span to avoid creating containing block so that OriginalDocSidebar keeps being sticky positioned to the existing block ancestor + // A span avoids creating a containing block for the sticky desktop sidebar. return ( - + ); diff --git a/src/theme/DocSidebar/styles.module.css b/src/theme/DocSidebar/styles.module.css index 8047d9b8..13bcabce 100644 --- a/src/theme/DocSidebar/styles.module.css +++ b/src/theme/DocSidebar/styles.module.css @@ -1,34 +1,18 @@ -/* avoids layout shift before Carbon gets a chance to initialize */ -.roomForCarbon > :first-child > :first-child { - height: calc(100% - 130px) !important; - flex-grow: initial; -} - -/* Fade at the bottom of the scrollable menu so items dissolve before the ad */ -.roomForCarbon > :first-child > :first-child { - position: relative; - -webkit-mask-image: linear-gradient( - to bottom, - black calc(100% - 28px), - transparent 100% - ); - mask-image: linear-gradient( - to bottom, - black calc(100% - 28px), - transparent 100% - ); -} - +/* An empty or blocked ad must not reduce the height of the sidebar menu. */ .carbonWrapper { + display: none; flex-shrink: 0; min-height: 130px; padding: 0 15px 15px 15px; overflow: hidden; - display: flex; justify-content: center; align-items: flex-end; } +.carbonLoaded { + display: flex; +} + :global(#carbonads) { display: block; background-color: var(--ifm-background-surface-color);