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..d35876a 100644 --- a/docs/press.config.tsx +++ b/docs/press.config.tsx @@ -2,10 +2,13 @@ import { defineDocs } from "fumadocs-mdx/macro"; import { defineConfig } from "fumapress"; import { fumadocsMdx } from "fumapress/adapters/mdx"; import { metaSchema, pageSchema } from "fumapress/adapters/mdx/schema"; +import { createDocsLayoutPage } from "fumapress/layouts/docs"; import { linkValidationPlugin } from "fumapress/plugins/link-validation"; import { sitemapPlugin } from "fumapress/plugins/sitemap"; import { takumiPlugin } from "fumapress/plugins/takumi"; +import { version } from "../package.json"; +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"; @@ -37,11 +40,17 @@ const OG = { export default defineConfig({ content: docs.toFumadocsSource(), defaultLayoutProps: { + // The sidebar's bottom strip is a bordered box holding whatever icon links + // and the theme switch it is given. With one icon link duplicating a nav + // link it was a full-width border around a lone button, so both go and the + // switch is rendered as the sidebar's own footer below. + githubUrl: "", links: [ { text: "GitHub", url: REPO }, { text: "Marketplace", url: MARKETPLACE }, ], nav: { title: SITE_NAME }, + themeSwitch: { enabled: false }, }, meta: { root() { @@ -84,6 +93,30 @@ export default defineConfig({ }, // Deployed to a CDN as a static site, so no route may depend on a server. mode: "static", + renderPage: createDocsLayoutPage({ + renderLayout({ next, props }) { + return next({ + ...props, + sidebar: { + ...props.sidebar, + footer: ( +
+ + v{version} + + +
+ ), + }, + }); + }, + }), site: { baseUrl: url, git: { diff --git a/docs/src/components/footer.tsx b/docs/src/components/footer.tsx index fd817dc..b6b5a13 100644 --- a/docs/src/components/footer.tsx +++ b/docs/src/components/footer.tsx @@ -1,6 +1,5 @@ -import { ThemeSwitch } from "fumadocs-ui/layouts/shared/slots/theme-switch"; - import { MARKETPLACE, REPO } from "../lib/links"; +import { ThemeSwitch } from "./theme-switch"; interface FooterColumn { heading: string; @@ -182,7 +181,7 @@ export const SiteFooter = () => ( {/* Closing the gap opened by not using Fumapress's own nav: without this the home page had no way to change appearance. */} - + diff --git a/docs/src/components/theme-switch.tsx b/docs/src/components/theme-switch.tsx new file mode 100644 index 0000000..ec324f2 --- /dev/null +++ b/docs/src/components/theme-switch.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { cn } from "cnfast"; +import { useTheme } from "fumadocs-ui/provider/base"; +import type { ComponentProps } from "react"; +import { useCallback, useId, useState, useSyncExternalStore } from "react"; +import { flushSync } from "react-dom"; + +type ThemeSwitchProps = ComponentProps<"fieldset"> & { + mode?: "light-dark" | "light-dark-system"; +}; + +interface Thumb { + left: number; + width: number; +} + +const THEMES = [ + { + 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>(); +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 [thumb, setThumb] = useState(null); + const name = useId(); + + const measure = useCallback((cell: HTMLLabelElement | null) => { + if (!cell) { + return; + } + + const place = () => + setThumb({ left: cell.offsetLeft, width: cell.offsetWidth }); + + place(); + const observer = new ResizeObserver(place); + observer.observe(cell); + if (cell.parentElement) { + observer.observe(cell.parentElement); + } + + return () => observer.disconnect(); + }, []); + + 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 change = (value: string) => { + if (document.startViewTransition) { + document.startViewTransition(() => flushSync(() => setTheme(value))); + } else { + setTheme(value); + } + }; + + return ( +
+ Theme + + {thumb ? ( +
+ ); +};