From 817fecf149861ea979ba1e77e84604bd8f84f1ec Mon Sep 17 00:00:00 2001 From: mrzmyr Date: Fri, 10 Apr 2026 21:18:39 +0200 Subject: [PATCH 1/2] feat(react): high-contrast syntax highlighting with light/dark pair The previous single Vitesse Dark theme was the same in both modes and lacked contrast. Switch to GitHub Light/Dark as the default pair and make the theme selectable at the component level. - `lib/shiki.ts`: export `SupportedTheme`, `DEFAULT_LIGHT_THEME`, `DEFAULT_DARK_THEME`, and a `ShikiThemeProp` that accepts either a single theme or a `{ light, dark }` pair. Add `useIsDark` and `useResolvedShikiTheme` hooks so components can react to the system color-scheme. Load both GitHub themes up-front. - `CodeBlock` and `ExpandableCodeBlock`: accept an optional `theme` prop and pass it through the tokenization hooks. - Streamdown plugin: key its cache per active theme so switching color schemes doesn't serve stale tokens. --- packages/react/src/components/code-block.tsx | 20 +++-- .../src/components/expandable-code-block.tsx | 19 +++-- packages/react/src/lib/shiki.ts | 74 +++++++++++++++++-- 3 files changed, 96 insertions(+), 17 deletions(-) diff --git a/packages/react/src/components/code-block.tsx b/packages/react/src/components/code-block.tsx index f2b870f3ba..1019593220 100644 --- a/packages/react/src/components/code-block.tsx +++ b/packages/react/src/components/code-block.tsx @@ -1,7 +1,13 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { jsx, jsxs, Fragment } from "react/jsx-runtime"; import { toJsxRuntime } from "hast-util-to-jsx-runtime"; -import { getHighlighter, resolveLang, THEME } from "../lib/shiki"; +import { + getHighlighter, + resolveLang, + useResolvedShikiTheme, + type ShikiThemeProp, + type SupportedTheme, +} from "../lib/shiki"; import { cn } from "../lib/utils"; import { Button } from "./button"; @@ -55,7 +61,7 @@ const CheckIcon = () => ( // Highlight hook // --------------------------------------------------------------------------- -function useHighlighted(code: string, lang: string): ReactNode | null { +function useHighlighted(code: string, lang: string, theme: SupportedTheme): ReactNode | null { const [highlighted, setHighlighted] = useState(null); useEffect(() => { @@ -64,7 +70,7 @@ function useHighlighted(code: string, lang: string): ReactNode | null { getHighlighter().then((highlighter) => { if (cancelled) return; - const hast = highlighter.codeToHast(code, { lang, theme: THEME }); + const hast = highlighter.codeToHast(code, { lang, theme }); const nodes = toJsxRuntime(hast, { jsx, jsxs, Fragment }); if (!cancelled) setHighlighted(nodes); @@ -73,7 +79,7 @@ function useHighlighted(code: string, lang: string): ReactNode | null { return () => { cancelled = true; }; - }, [code, lang]); + }, [code, lang, theme]); return highlighted; } @@ -88,13 +94,15 @@ export function CodeBlock(props: { title?: string; maxHeight?: string; className?: string; + theme?: ShikiThemeProp; }) { - const { code, lang: langHint, title, className } = props; + const { code, lang: langHint, title, className, theme } = props; const [expanded, setExpanded] = useState(false); const [copied, setCopied] = useState(false); const language = useMemo(() => detectLanguage(code, langHint), [code, langHint]); - const highlighted = useHighlighted(code, language); + const resolvedTheme = useResolvedShikiTheme(theme); + const highlighted = useHighlighted(code, language, resolvedTheme); const lines = code.split("\n"); const isLong = lines.length > 24; diff --git a/packages/react/src/components/expandable-code-block.tsx b/packages/react/src/components/expandable-code-block.tsx index 9247871a5b..b250e2a92c 100644 --- a/packages/react/src/components/expandable-code-block.tsx +++ b/packages/react/src/components/expandable-code-block.tsx @@ -1,5 +1,10 @@ import { useCallback, useEffect, useMemo, useState, startTransition } from "react"; -import { getHighlighter, THEME } from "../lib/shiki"; +import { + getHighlighter, + useResolvedShikiTheme, + type ShikiThemeProp, + type SupportedTheme, +} from "../lib/shiki"; import { cn } from "../lib/utils"; import { Button } from "./button"; import type { ThemedToken } from "shiki/core"; @@ -98,7 +103,7 @@ const CheckIcon = () => ( // Shiki tokenization hook — non-blocking // --------------------------------------------------------------------------- -function useTokens(code: string): ThemedToken[][] | null { +function useTokens(code: string, theme: SupportedTheme): ThemedToken[][] | null { const [tokens, setTokens] = useState(null); useEffect(() => { @@ -107,7 +112,7 @@ function useTokens(code: string): ThemedToken[][] | null { if (cancelled) return; const result = highlighter.codeToTokens(code, { lang: "typescript", - theme: THEME, + theme, }); if (!cancelled) { startTransition(() => setTokens(result.tokens)); @@ -116,7 +121,7 @@ function useTokens(code: string): ThemedToken[][] | null { return () => { cancelled = true; }; - }, [code]); + }, [code, theme]); return tokens; } @@ -300,8 +305,10 @@ export function ExpandableCodeBlock(props: { code: string; definitions?: readonly Definition[]; className?: string; + theme?: ShikiThemeProp; }) { - const { code, definitions = [], className } = props; + const { code, definitions = [], className, theme } = props; + const resolvedTheme = useResolvedShikiTheme(theme); // Auto-expand trivial aliases (primitives, simple unions, string literals) const trivialNames = useMemo(() => { const trivial = new Set(); @@ -348,7 +355,7 @@ export function ExpandableCodeBlock(props: { return formatTypeScript(withExpansions); }, [code, allExpanded, definitionMap, emptyAncestors]); - const tokens = useTokens(displayCode); + const tokens = useTokens(displayCode, resolvedTheme); const handleToggle = useCallback((name: string) => { setExpanded((prev) => { diff --git a/packages/react/src/lib/shiki.ts b/packages/react/src/lib/shiki.ts index 5fcb032b96..8084f8636a 100644 --- a/packages/react/src/lib/shiki.ts +++ b/packages/react/src/lib/shiki.ts @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import { createHighlighterCore, type HighlighterCore, type LanguageInput } from "shiki/core"; import { createJavaScriptRegexEngine } from "shiki/engine/javascript"; @@ -96,7 +97,52 @@ const LANG_LOADERS: Record LanguageInput> = { const supportedSet = new Set([...SUPPORTED_LANGS, ...Object.keys(LANG_ALIASES)]); -export const THEME = "vitesse-dark"; +export const THEME = "github-light"; + +export const SUPPORTED_THEMES = ["vitesse-dark", "github-dark", "github-light"] as const; +export type SupportedTheme = (typeof SUPPORTED_THEMES)[number]; + +export const DEFAULT_LIGHT_THEME: SupportedTheme = "github-light"; +export const DEFAULT_DARK_THEME: SupportedTheme = "github-dark"; + +export type ShikiThemeProp = + | SupportedTheme + | { light: SupportedTheme; dark: SupportedTheme }; + +/** + * Returns `true` when the user's system prefers a dark color scheme. + * Reactive: updates when the media query changes. + */ +export function useIsDark(): boolean { + const [dark, setDark] = useState(() => { + if (typeof window === "undefined") return false; + return window.matchMedia("(prefers-color-scheme: dark)").matches; + }); + + useEffect(() => { + if (typeof window === "undefined") return; + const mql = window.matchMedia("(prefers-color-scheme: dark)"); + const handler = (event: MediaQueryListEvent) => setDark(event.matches); + mql.addEventListener("change", handler); + return () => mql.removeEventListener("change", handler); + }, []); + + return dark; +} + +/** + * Resolve a `ShikiThemeProp` (either a single theme or a `{ light, dark }` + * pair) to the theme that should currently be used, reacting to system + * dark-mode changes. When no theme is provided, the default github pair is + * used. + */ +export function useResolvedShikiTheme(theme?: ShikiThemeProp): SupportedTheme { + const isDark = useIsDark(); + if (typeof theme === "string") return theme; + const light = theme?.light ?? DEFAULT_LIGHT_THEME; + const dark = theme?.dark ?? DEFAULT_DARK_THEME; + return isDark ? dark : light; +} export function resolveLang(lang: string): SupportedLang | null { const l = lang.trim().toLowerCase(); @@ -120,7 +166,11 @@ let _promise: Promise | null = null; export function getHighlighter(): Promise { if (!_promise) { _promise = createHighlighterCore({ - themes: [import("@shikijs/themes/vitesse-dark")], + themes: [ + import("@shikijs/themes/vitesse-dark"), + import("@shikijs/themes/github-dark"), + import("@shikijs/themes/github-light"), + ], langs: Object.values(LANG_LOADERS).map((loader) => loader()), engine: jsEngine, }); @@ -137,17 +187,31 @@ import type { CodeHighlighterPlugin, ThemeInput } from "streamdown"; const tokensCache = new Map(); const pendingCallbacks = new Map void>>(); +/** + * Read the current system color-scheme preference synchronously. Used in + * non-React contexts (like the streamdown plugin) where hooks aren't + * available. + */ +const prefersDarkNow = (): boolean => { + if (typeof window === "undefined") return false; + return window.matchMedia("(prefers-color-scheme: dark)").matches; +}; + export function createCodeHighlighterPlugin(): CodeHighlighterPlugin { return { name: "shiki" as const, type: "code-highlighter" as const, getSupportedLanguages: () => [...SUPPORTED_LANGS] as string[] as never, - getThemes: () => [THEME as ThemeInput, THEME as ThemeInput], + getThemes: () => [ + DEFAULT_LIGHT_THEME as ThemeInput, + DEFAULT_DARK_THEME as ThemeInput, + ], supportsLanguage: (language: string) => isSupportedLang(language), highlight(options, callback) { const resolved = resolveLang(options.language); const lang = resolved ?? "json"; - const key = `${lang}:${options.code.length}:${options.code.slice(0, 128)}`; + const activeTheme = prefersDarkNow() ? DEFAULT_DARK_THEME : DEFAULT_LIGHT_THEME; + const key = `${activeTheme}:${lang}:${options.code.length}:${options.code.slice(0, 128)}`; const cached = tokensCache.get(key); if (cached) return cached as never; @@ -168,7 +232,7 @@ export function createCodeHighlighterPlugin(): CodeHighlighterPlugin { } const result = highlighter.codeToTokens(options.code, { lang, - themes: { light: THEME, dark: THEME }, + themes: { light: activeTheme, dark: activeTheme }, }); tokensCache.set(key, result); pendingCallbacks.get(key)?.forEach((cb) => cb(result)); From 0986708c041eae5cad849be43153ab9211271a3f Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:14:50 -0700 Subject: [PATCH 2/2] refactor(react): extract useIsDark hook from shiki module Move useIsDark into its own hook file so it can be reused outside of syntax highlighting. Also remove unused THEME export and vitesse-dark theme. --- packages/react/src/hooks/use-is-dark.ts | 22 +++++++++++++++++++ packages/react/src/lib/shiki.ts | 28 ++----------------------- 2 files changed, 24 insertions(+), 26 deletions(-) create mode 100644 packages/react/src/hooks/use-is-dark.ts diff --git a/packages/react/src/hooks/use-is-dark.ts b/packages/react/src/hooks/use-is-dark.ts new file mode 100644 index 0000000000..f13aa9fb83 --- /dev/null +++ b/packages/react/src/hooks/use-is-dark.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from "react"; + +/** + * Returns `true` when the user's system prefers a dark color scheme. + * Reactive: updates when the media query changes. + */ +export function useIsDark(): boolean { + const [dark, setDark] = useState(() => { + if (typeof window === "undefined") return false; + return window.matchMedia("(prefers-color-scheme: dark)").matches; + }); + + useEffect(() => { + if (typeof window === "undefined") return; + const mql = window.matchMedia("(prefers-color-scheme: dark)"); + const handler = (event: MediaQueryListEvent) => setDark(event.matches); + mql.addEventListener("change", handler); + return () => mql.removeEventListener("change", handler); + }, []); + + return dark; +} diff --git a/packages/react/src/lib/shiki.ts b/packages/react/src/lib/shiki.ts index 8084f8636a..2fe1e4ce7c 100644 --- a/packages/react/src/lib/shiki.ts +++ b/packages/react/src/lib/shiki.ts @@ -1,6 +1,6 @@ -import { useEffect, useState } from "react"; import { createHighlighterCore, type HighlighterCore, type LanguageInput } from "shiki/core"; import { createJavaScriptRegexEngine } from "shiki/engine/javascript"; +import { useIsDark } from "../hooks/use-is-dark"; // --------------------------------------------------------------------------- // Supported languages — explicit imports to avoid bundling all grammars @@ -97,9 +97,7 @@ const LANG_LOADERS: Record LanguageInput> = { const supportedSet = new Set([...SUPPORTED_LANGS, ...Object.keys(LANG_ALIASES)]); -export const THEME = "github-light"; - -export const SUPPORTED_THEMES = ["vitesse-dark", "github-dark", "github-light"] as const; +export const SUPPORTED_THEMES = ["github-dark", "github-light"] as const; export type SupportedTheme = (typeof SUPPORTED_THEMES)[number]; export const DEFAULT_LIGHT_THEME: SupportedTheme = "github-light"; @@ -109,27 +107,6 @@ export type ShikiThemeProp = | SupportedTheme | { light: SupportedTheme; dark: SupportedTheme }; -/** - * Returns `true` when the user's system prefers a dark color scheme. - * Reactive: updates when the media query changes. - */ -export function useIsDark(): boolean { - const [dark, setDark] = useState(() => { - if (typeof window === "undefined") return false; - return window.matchMedia("(prefers-color-scheme: dark)").matches; - }); - - useEffect(() => { - if (typeof window === "undefined") return; - const mql = window.matchMedia("(prefers-color-scheme: dark)"); - const handler = (event: MediaQueryListEvent) => setDark(event.matches); - mql.addEventListener("change", handler); - return () => mql.removeEventListener("change", handler); - }, []); - - return dark; -} - /** * Resolve a `ShikiThemeProp` (either a single theme or a `{ light, dark }` * pair) to the theme that should currently be used, reacting to system @@ -167,7 +144,6 @@ export function getHighlighter(): Promise { if (!_promise) { _promise = createHighlighterCore({ themes: [ - import("@shikijs/themes/vitesse-dark"), import("@shikijs/themes/github-dark"), import("@shikijs/themes/github-light"), ],