From de9fc219fc1d07c0012eec4988026a11d9e69a07 Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:03:24 +0000 Subject: [PATCH 01/13] feat(docs): swap the theme switcher for a segmented pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fumadocs' switch highlights whichever of its three buttons is active and cross-fades between them, so a change of theme reads as two events: one control losing its background while another gains one. This is a pill of three equal cells with a single thumb sliding between them, so the change reads as one motion. The thumb renders only once a theme is known — the server cannot know it, and an element present from the first paint would have to slide in from the first cell on hydration. The layouts style the default switch through `className`: the docs sidebar passes `rounded-none` and `*:rounded-md`, flux passes `rounded-xl`. The pill therefore merges its own classes last, and carries `*:rounded-full` of its own, so it keeps its shape wherever a layout drops it. That merge is what `cnfast` is for; it is already in the tree as a dependency of Fumadocs UI, now declared. --- docs/bun.lock | 1 + docs/package.json | 1 + docs/press.config.tsx | 2 + docs/src/components/theme-switch.tsx | 89 ++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+) create mode 100644 docs/src/components/theme-switch.tsx diff --git a/docs/bun.lock b/docs/bun.lock index ab97619..aa7857e 100644 --- a/docs/bun.lock +++ b/docs/bun.lock @@ -6,6 +6,7 @@ "name": "docs", "dependencies": { "@base-ui/react": "^1.6.0", + "cnfast": "^0.1.0", "fumadocs-core": "^16.14.0", "fumadocs-mdx": "^15.2.2", "fumadocs-ui": "npm:@fumadocs/base-ui@^16.14.0", diff --git a/docs/package.json b/docs/package.json index b8469c4..ed0cdcd 100644 --- a/docs/package.json +++ b/docs/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@base-ui/react": "^1.6.0", + "cnfast": "^0.1.0", "fumadocs-core": "^16.14.0", "fumadocs-mdx": "^15.2.2", "fumadocs-ui": "npm:@fumadocs/base-ui@^16.14.0", diff --git a/docs/press.config.tsx b/docs/press.config.tsx index 2488c64..d9ce7ca 100644 --- a/docs/press.config.tsx +++ b/docs/press.config.tsx @@ -6,6 +6,7 @@ import { linkValidationPlugin } from "fumapress/plugins/link-validation"; import { sitemapPlugin } from "fumapress/plugins/sitemap"; import { takumiPlugin } from "fumapress/plugins/takumi"; +import { ThemeSwitch } from "./src/components/theme-switch"; import { MARKETPLACE, REPO } from "./src/lib/links"; import { MARK_VIEW_BOX, markPath } from "./src/lib/mark"; import { url } from "./src/lib/url"; @@ -42,6 +43,7 @@ export default defineConfig({ { text: "Marketplace", url: MARKETPLACE }, ], nav: { title: SITE_NAME }, + slots: { themeSwitch: ThemeSwitch }, }, meta: { root() { diff --git a/docs/src/components/theme-switch.tsx b/docs/src/components/theme-switch.tsx new file mode 100644 index 0000000..ee82849 --- /dev/null +++ b/docs/src/components/theme-switch.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { cn } from "cnfast"; +import { useTheme } from "fumadocs-ui/provider/base"; +import type { ComponentProps } from "react"; +import { useSyncExternalStore } from "react"; +import { flushSync } from "react-dom"; + +type ThemeSwitchProps = ComponentProps<"div"> & { + mode?: "light-dark" | "light-dark-system"; +}; + +const THEMES = [ + { glyph: "🖥", label: "System theme", value: "system" }, + { glyph: "☉", label: "Light theme", value: "light" }, + { glyph: "☾", label: "Dark theme", value: "dark" }, +] as const; + +const STEP = 100; + +const listeners = new Set<() => void>(); +const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); +}; +const onClient = () => true; +const onServer = () => false; + +export const ThemeSwitch = ({ + className, + mode = "light-dark-system", + ...props +}: ThemeSwitchProps) => { + const { resolvedTheme, setTheme, theme } = useTheme(); + const hydrated = useSyncExternalStore(subscribe, onClient, onServer); + + const themes = + mode === "light-dark" + ? THEMES.filter((option) => option.value !== "system") + : THEMES; + const selected = mode === "light-dark" ? resolvedTheme : theme; + const active = hydrated ? selected : null; + const index = themes.findIndex((option) => option.value === active); + + const change = (value: string) => { + if (document.startViewTransition) { + document.startViewTransition(() => flushSync(() => setTheme(value))); + } else { + setTheme(value); + } + }; + + return ( +
+ {index !== -1 && ( + + ); +}; From 123a034207bb3b86722cdc1df18b261a178f360a Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:29:47 +0000 Subject: [PATCH 02/13] fix(docs): size the theme switcher's cells to their glyphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cells were square and a fixed 28px, so the thumb could move in whole cell widths and never had to be measured. The glyphs are not square: 🖥 is half again as wide as ☾, so a square cell crops the padding around the wide one and hangs it around the narrow ones. The cells are padding-sized now, and the thumb takes the position and the width of the active one from the DOM — one `ResizeObserver` per active cell, through a ref callback rather than an effect, so the measurement lands before the browser paints. `left` and `width` are what animate, which is also what makes the thumb resize as it travels. --- docs/src/components/theme-switch.tsx | 39 +++++++++++++++++++++------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/docs/src/components/theme-switch.tsx b/docs/src/components/theme-switch.tsx index ee82849..8eae3ed 100644 --- a/docs/src/components/theme-switch.tsx +++ b/docs/src/components/theme-switch.tsx @@ -3,21 +3,24 @@ import { cn } from "cnfast"; import { useTheme } from "fumadocs-ui/provider/base"; import type { ComponentProps } from "react"; -import { useSyncExternalStore } from "react"; +import { useCallback, useState, useSyncExternalStore } from "react"; import { flushSync } from "react-dom"; type ThemeSwitchProps = ComponentProps<"div"> & { mode?: "light-dark" | "light-dark-system"; }; +interface Thumb { + left: number; + width: number; +} + const THEMES = [ { glyph: "🖥", label: "System theme", value: "system" }, { glyph: "☉", label: "Light theme", value: "light" }, { glyph: "☾", label: "Dark theme", value: "dark" }, ] as const; -const STEP = 100; - const listeners = new Set<() => void>(); const subscribe = (listener: () => void) => { listeners.add(listener); @@ -33,6 +36,22 @@ export const ThemeSwitch = ({ }: ThemeSwitchProps) => { const { resolvedTheme, setTheme, theme } = useTheme(); const hydrated = useSyncExternalStore(subscribe, onClient, onServer); + const [thumb, setThumb] = useState(null); + + const measure = useCallback((cell: HTMLButtonElement | null) => { + if (!cell) { + return; + } + + const place = () => + setThumb({ left: cell.offsetLeft, width: cell.offsetWidth }); + + place(); + const observer = new ResizeObserver(place); + observer.observe(cell); + + return () => observer.disconnect(); + }, []); const themes = mode === "light-dark" @@ -40,7 +59,6 @@ export const ThemeSwitch = ({ : THEMES; const selected = mode === "light-dark" ? resolvedTheme : theme; const active = hydrated ? selected : null; - const index = themes.findIndex((option) => option.value === active); const change = (value: string) => { if (document.startViewTransition) { @@ -54,31 +72,32 @@ export const ThemeSwitch = ({
- {index !== -1 && ( + {thumb ? ( {/* Closing the gap opened by not using Fumapress's own nav: without this the home page had no way to change appearance. */} - +
From 26d63c054031f868b90b53e0428d09fcd907e087 Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:40:35 +0000 Subject: [PATCH 04/13] fix(docs): draw the theme switcher with Geist icons The glyphs were characters, and a character is at the mercy of whatever font the platform resolves it to. `U+1F5A5 DESKTOP COMPUTER` is text presentation by default, but almost no text font carries it, so most systems fell through to a colour emoji font: a full-colour monitor next to two thin monochrome symbols, at a size and baseline neither of them shared. The three Geist icons are one 16px grid, `currentColor` throughout, so they inherit the cell's colour and its states. The cells come out 36px wide, which is what the design they are modelled on measures. --- docs/src/components/theme-switch.tsx | 36 ++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/docs/src/components/theme-switch.tsx b/docs/src/components/theme-switch.tsx index 8eae3ed..17472fe 100644 --- a/docs/src/components/theme-switch.tsx +++ b/docs/src/components/theme-switch.tsx @@ -16,9 +16,30 @@ interface Thumb { } const THEMES = [ - { glyph: "🖥", label: "System theme", value: "system" }, - { glyph: "☉", label: "Light theme", value: "light" }, - { glyph: "☾", label: "Dark theme", value: "dark" }, + { + icon: { + d: "M2.5 5.25C2.5 3.45 3.96 2 5.75 2h4.5c1.8 0 3.25 1.46 3.25 3.25V14h-11V5.25M5.75 3.5C4.78 3.5 4 4.28 4 5.25v7.25h8V5.25c0-.97-.78-1.75-1.75-1.75zM5 5.5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1V9H5zm3.5 6H11V10H8.5z", + rule: "evenodd", + }, + label: "System theme", + value: "system", + }, + { + icon: { + d: "M8.75 2v-.75h-1.5V3h1.5V2M8 10a2 2 0 1 0 0-4 2 2 0 0 0 0 4m0 1.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7m.75 1.5v1.75h-1.5V13zM13 7.25h1.75v1.5H13zm-11 0h-.75v1.5H3v-1.5H2m9-3.32.54-.53.17-.17.53-.53 1.06 1.06-.53.53-.17.17-.53.53zm-7.77 7.78-.53.53 1.06 1.06.53-.53.17-.17.53-.53L3.93 11l-.53.53zM3.93 5l-.53-.53-.17-.17-.53-.53L3.76 2.7l.53.53.17.17.53.53zm7.78 7.78.53.53 1.06-1.06-.53-.53-.17-.17-.53-.53L11 12.07l.53.53z", + rule: "evenodd", + }, + label: "Light theme", + value: "light", + }, + { + icon: { + d: "m6.3 3.3.7.25A4.25 4.25 0 0 0 12.45 9l.96.96-.08.2A5.75 5.75 0 1 1 6.04 2.6zM5.25 4.76a4.24 4.24 0 1 0 6 5.99H11a5.75 5.75 0 0 1-5.75-6M12.5 3.5h1.25V5H12.5v1.25H11V5H9.75V3.5H11V2.25h1.5zM7 3.55l-.7-.25-.26-.7z", + rule: "nonzero", + }, + label: "Dark theme", + value: "dark", + }, ] as const; const listeners = new Set<() => void>(); @@ -100,7 +121,14 @@ export const ThemeSwitch = ({ ref={active === option.value ? measure : null} type="button" > - + ))} From 83779e5022a14e0a753a5c5c400d9dfd5f6f53d9 Mon Sep 17 00:00:00 2001 From: PunGrumpy <108584943+PunGrumpy@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:50:00 +0000 Subject: [PATCH 05/13] fix(docs): make the theme switcher visible on the footer's card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pill was a fill and nothing else: `bg-fd-secondary/50` over `bg-fd-card` measures 1.03:1, which is not a surface, it is the same surface. On the home page's footer the control had no edge at all, and the thumb marking the active theme sat at 1.21:1 against the track it travels along — the two cues that say "this is a control" and "this one is selected" were both invisible. The track takes the hairline the rest of the site gives a bordered control (`header.tsx` bounds its ghost pill at `foreground/20`), and the thumb now carries a raised fill and a `foreground/50` ring: 3.02:1 in light and 4.61:1 in dark against the track, over the 3:1 that WCAG 1.4.11 asks of anything that identifies a state. It is also how the switcher this is modelled on marks its checked cell — a 1px ring, not a fill. `text-sm` and `text-center` went with it. Since the cells became icons they had nothing left to size or align. --- docs/src/components/theme-switch.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/components/theme-switch.tsx b/docs/src/components/theme-switch.tsx index 17472fe..9df1051 100644 --- a/docs/src/components/theme-switch.tsx +++ b/docs/src/components/theme-switch.tsx @@ -93,7 +93,7 @@ export const ThemeSwitch = ({