feat: improve themes (#150)

- removed code themes in favour of themes having built-in code syntax
themes.

# Themes (30)

## Supabase
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/ebe48a46-93ee-4bea-9cc8-dda97531a2c8"
/>

## Vercel
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/9736aa0d-7b72-47d7-bf24-8416aeb14b08"
/>

## Evil Rabbit
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/7e63a20d-634d-4143-8179-2b41129412b9"
/>

## Mintlify
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/67d61fb8-a9d0-44da-9d65-2dd2e5b35214"
/>

## Tailwind
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/88bc8177-f5a3-45e9-ba39-6ff3fdceb362"
/>

## OpenAI
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/432386b2-a7fb-40d0-85a7-7ed0930a5f3f"
/>

## Prisma
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/73a7b0a1-8e09-4321-b21e-c2baaf866cfa"
/>

## ElevenLabs
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/57de3e05-5924-40f2-993c-c0ba651bb6ed"
/>

## Resend
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/a9855ba8-135d-4b5c-a77d-0c081dcf552b"
/>

## Trigger.dev
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/f833d2f6-0f02-4f85-b8ba-da20ce489c98"
/>

## Nuxt
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/80cf39d5-b04c-4914-b0fe-e057581827a4"
/>

## BrowserBase
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/6be2b610-0e67-4149-8e7c-6ad3e670a96d"
/>

## Cloudflare
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/3dd6847d-e848-4718-af26-4fae755943b1"
/>

## Gemini
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/a1ba05fd-4299-4f6b-8211-18182ec96a5e"
/>

## Stripe
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/570cf5c9-d00b-4306-b7fe-72cbd6eeb941"
/>

## Bitmap
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/42e975b4-f1db-443f-9bac-daff02fdea7f"
/>

## Nior
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/0f270ede-0325-4061-9662-e4db4e06e44b"
/>

## Ice
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/96de2a91-61b9-46c6-a00f-063b6f262559"
/>

## Sand
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/162404fd-b1c6-4991-bf8f-c875fead82b0"
/>

## Forest
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/df5e64fc-d31f-4dce-b249-4bedb17e10aa"
/>

## Mono
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/f9f3e93a-c70c-457a-b214-4fecdfd31992"
/>

## Breeze
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/227fb948-ab69-44f5-bb03-16df96a73bd3"
/>

## Candy
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/e952135d-0e7b-4b7e-9e27-8259e78bd71c"
/>

## Crismon
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/8fd0ffd4-48a4-4739-8d7b-befa2379eab3"
/>

## Falcon
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/19a03c00-f31b-4977-ad7b-4a6ef70e115e"
/>

## Meadow
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/31bae90b-dc5b-4e2e-85d7-6404e1cb213c"
/>

## Midnight
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/aa2aecce-1865-4eee-a653-f43490bcc895"
/>

## Raindrop
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/793e012c-c6bb-4467-9408-48670754d4f7"
/>

## Sunset
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/34a05617-7502-447e-aba3-9fcda7564c0e"
/>

## Firecrawl
<img width="2820" height="2124" alt="image"
src="https://github.com/user-attachments/assets/25ef8dc0-352f-4f81-b00f-df1ca4d3d59d"
/>
This commit is contained in:
Maxwell
2026-02-27 10:33:14 +10:00
committed by GitHub
parent 88481b2845
commit f6bcb9988f
34 changed files with 6840 additions and 2439 deletions
+2 -1
View File
@@ -169,7 +169,8 @@ model UserSettings {
userId String @id
displayName String?
theme String @default("system")
colorTheme String @default("midnight")
colorTheme String @default("hub")
colorMode String @default("dark")
ghostModel String @default("auto")
useOwnApiKey Boolean @default(false)
openrouterApiKey String?
+69 -84
View File
@@ -9,7 +9,6 @@ import { getNotifications, checkIsStarred } from "@/lib/github";
import { type GhostTabState } from "@/lib/chat-store";
import type { NotificationItem } from "@/lib/github-types";
import { ColorThemeProvider } from "@/components/theme/theme-provider";
import { CodeThemeProvider } from "@/components/theme/code-theme-provider";
import { GitHubLinkInterceptor } from "@/components/shared/github-link-interceptor";
import { TooltipProvider } from "@/components/ui/tooltip";
import { MutationEventProvider } from "@/components/shared/mutation-event-provider";
@@ -51,91 +50,77 @@ export default async function AppLayout({ children }: { children: React.ReactNod
<GlobalChatProvider initialTabState={initialTabState}>
<MutationEventProvider>
<ColorThemeProvider>
<CodeThemeProvider>
<GitHubLinkInterceptor>
<TooltipProvider>
<NavigationProgress />
<div className="flex flex-col h-dvh overflow-y-auto lg:overflow-hidden">
<AppNavbar
session={
session
}
notifications={
notifications
}
/>
<div className="mt-10 lg:h-[calc(100dvh-var(--spacing)*10)] flex flex-col px-2 sm:px-4 pt-2 lg:overflow-auto">
{children}
</div>
<Suspense>
<GlobalChatPanel />
</Suspense>
</div>
<OnboardingOverlay
userName={
session
?.githubUser
?.name ||
session
?.githubUser
?.login ||
""
}
userAvatar={
session
?.githubUser
?.avatar_url ||
""
}
bio={
session
?.githubUser
?.bio ||
""
}
company={
session
?.githubUser
?.company ||
""
}
location={
session
?.githubUser
?.location ||
""
}
publicRepos={
session
?.githubUser
?.public_repos ??
0
}
followers={
session
?.githubUser
?.followers ??
0
}
createdAt={
session
?.githubUser
?.created_at ||
""
}
onboardingDone={
onboardingDone
}
initialStarredAuth={
initialStarredAuth
}
initialStarredHub={
initialStarredHub
<GitHubLinkInterceptor>
<TooltipProvider>
<NavigationProgress />
<div className="flex flex-col h-dvh overflow-y-auto lg:overflow-hidden">
<AppNavbar
session={session}
notifications={
notifications
}
/>
</TooltipProvider>
</GitHubLinkInterceptor>
</CodeThemeProvider>
<div className="mt-10 lg:h-[calc(100dvh-var(--spacing)*10)] flex flex-col px-2 sm:px-4 pt-2 lg:overflow-auto">
{children}
</div>
<Suspense>
<GlobalChatPanel />
</Suspense>
</div>
<OnboardingOverlay
userName={
session?.githubUser
?.name ||
session?.githubUser
?.login ||
""
}
userAvatar={
session?.githubUser
?.avatar_url ||
""
}
bio={
session?.githubUser
?.bio || ""
}
company={
session?.githubUser
?.company ||
""
}
location={
session?.githubUser
?.location ||
""
}
publicRepos={
session?.githubUser
?.public_repos ??
0
}
followers={
session?.githubUser
?.followers ??
0
}
createdAt={
session?.githubUser
?.created_at ||
""
}
onboardingDone={
onboardingDone
}
initialStarredAuth={
initialStarredAuth
}
initialStarredHub={
initialStarredHub
}
/>
</TooltipProvider>
</GitHubLinkInterceptor>
</ColorThemeProvider>
</MutationEventProvider>
</GlobalChatProvider>
-108
View File
@@ -1,108 +0,0 @@
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import {
getCustomThemes,
saveCustomTheme,
deleteCustomTheme,
extractColorsFromVSCodeTheme,
} from "@/lib/code-themes/store";
export async function GET() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return new Response("Unauthorized", { status: 401 });
}
const themes = await getCustomThemes(session.user.id);
return Response.json(
themes.map((t) => ({
id: t.id,
name: t.name,
mode: t.mode,
bgColor: t.bgColor,
fgColor: t.fgColor,
accentColor: t.accentColor,
})),
);
}
export async function POST(request: Request) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return new Response("Unauthorized", { status: 401 });
}
let body: { name?: string; themeJson?: unknown };
try {
body = await request.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const { name, themeJson } = body;
if (!name || typeof name !== "string") {
return Response.json({ error: "Name is required" }, { status: 400 });
}
if (!themeJson || typeof themeJson !== "object") {
return Response.json({ error: "Theme JSON is required" }, { status: 400 });
}
// Validate it looks like a VS Code theme
const json = themeJson as Record<string, unknown>;
if (!json.tokenColors && !json.colors) {
return Response.json(
{ error: "Invalid VS Code theme: must contain tokenColors or colors" },
{ status: 400 },
);
}
try {
const { bg, fg, accent, mode } = extractColorsFromVSCodeTheme(json);
const theme = await saveCustomTheme(
session.user.id,
name,
mode,
JSON.stringify(themeJson),
bg,
fg,
accent,
);
return Response.json({
id: theme.id,
name: theme.name,
mode: theme.mode,
bgColor: theme.bgColor,
fgColor: theme.fgColor,
accentColor: theme.accentColor,
});
} catch {
return Response.json({ error: "Failed to save theme" }, { status: 500 });
}
}
export async function DELETE(request: Request) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return new Response("Unauthorized", { status: 401 });
}
let body: { id?: string };
try {
body = await request.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const { id } = body;
if (!id || typeof id !== "string") {
return Response.json({ error: "Theme ID is required" }, { status: 400 });
}
const deleted = await deleteCustomTheme(id, session.user.id);
if (!deleted) {
return Response.json({ error: "Theme not found" }, { status: 404 });
}
return Response.json({ ok: true });
}
@@ -14,6 +14,7 @@ const settingsUpdateSchema = z
displayName: z.string().max(100).optional(),
theme: z.enum(["light", "dark", "system"]).optional(),
colorTheme: z.string().max(50).optional(),
colorMode: z.enum(["light", "dark"]).optional(),
ghostModel: z.string().max(100).optional(),
useOwnApiKey: z.boolean().optional(),
openrouterApiKey: z.string().max(500).nullable().optional(),
+6 -6
View File
@@ -233,7 +233,7 @@ body {
font-size: var(--code-font-size, 13px);
line-height: 20px;
--cv-gutter-w: 3ch;
background: var(--code-theme-bg, var(--code-bg));
background: var(--code-bg);
}
.code-content pre {
@@ -334,7 +334,7 @@ tr.diff-search-match-active > td {
/* Shiki dual-theme (defaultColor: false) */
.code-content .shiki {
background-color: var(--code-theme-bg, var(--code-bg)) !important;
background-color: var(--code-bg) !important;
}
:root:where(.dark) .code-content .shiki span {
color: var(--shiki-dark) !important;
@@ -499,7 +499,7 @@ tr.diff-search-match-active > td {
margin-top: 0;
margin-bottom: 1em;
font-family: var(--code-font-override, var(--font-code)), ui-monospace, monospace;
background-color: var(--code-theme-bg, var(--code-block-bg));
background-color: var(--code-block-bg);
}
/* Shiki dual-theme support (defaultColor: false) */
@@ -508,7 +508,7 @@ tr.diff-search-match-active > td {
font-family: var(--code-font-override, var(--font-code)), ui-monospace, monospace;
}
.ghmd .shiki {
background-color: var(--code-theme-bg, var(--code-block-bg)) !important;
background-color: var(--code-block-bg) !important;
}
:root:where(.dark) .ghmd .shiki span {
color: var(--shiki-dark) !important;
@@ -520,7 +520,7 @@ tr.diff-search-match-active > td {
}
/* Override shiki's inline bg in markdown context */
.ghmd pre.shiki {
background-color: var(--code-theme-bg, var(--code-block-bg)) !important;
background-color: var(--code-block-bg) !important;
}
/* Code block wrapper with copy button */
@@ -542,7 +542,7 @@ tr.diff-search-match-active > td {
justify-content: center;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--code-theme-bg, var(--code-block-bg));
background: var(--code-block-bg);
color: var(--muted-foreground);
cursor: pointer;
opacity: 0;
+69 -386
View File
@@ -43,8 +43,6 @@ import { getLanguageColor } from "@/lib/github-utils";
import { useGlobalChatOptional } from "@/components/shared/global-chat-provider";
import { getRecentViews, type RecentViewItem } from "@/lib/recent-views";
import { useColorTheme } from "@/components/theme/theme-provider";
import { useCodeTheme } from "@/components/theme/code-theme-provider";
import { BUILT_IN_THEMES } from "@/lib/code-themes/built-in";
import { useMutationEvents } from "@/components/shared/mutation-event-provider";
import { useMutationSubscription } from "@/hooks/use-mutation-subscription";
import type { MutationEvent } from "@/lib/mutation-events";
@@ -104,15 +102,7 @@ function matchRepoFromPathname(pathname: string): [string, string] | null {
return [segments[0], segments[1]];
}
type Mode =
| "commands"
| "search"
| "theme"
| "code-theme"
| "accounts"
| "settings"
| "model"
| "files";
type Mode = "commands" | "search" | "theme" | "accounts" | "settings" | "model" | "files";
export function CommandMenu() {
const [open, setOpen] = useState(false);
@@ -127,17 +117,12 @@ export function CommandMenu() {
const panelRef = useRef<HTMLDivElement>(null);
const globalChat = useGlobalChatOptional();
const {
colorTheme,
setColorTheme,
themeId: currentThemeId,
mode: currentMode,
setTheme: setColorTheme,
toggleMode,
themes: colorThemes,
darkThemes: colorDarkThemes,
lightThemes: colorLightThemes,
darkThemeId,
lightThemeId,
mode: _colorMode,
} = useColorTheme();
const { codeThemeDark, codeThemeLight, setCodeThemeDark, setCodeThemeLight } =
useCodeTheme();
const { emit } = useMutationEvents();
// Recently viewed
@@ -789,29 +774,13 @@ export function CommandMenu() {
"wave",
"contrast",
"style",
"code",
"syntax",
],
action: () => switchMode("theme"),
icon: Palette,
keepOpen: true,
},
{
name: "Code Theme",
description: "Change syntax highlighting",
keywords: [
"code",
"syntax",
"editor",
"highlight",
"dracula",
"monokai",
"catppuccin",
"shiki",
"font",
],
action: () => switchMode("code-theme"),
icon: Code,
keepOpen: true,
},
{
name: "Account Settings",
description: "Profile, accounts & sign out",
@@ -1126,32 +1095,16 @@ export function CommandMenu() {
}, [search, topUserRepos, filteredUserRepos, dedupedGithubResults, router]);
// --- Theme mode items ---
const filteredDarkThemes = useMemo(() => {
if (mode !== "theme") return colorDarkThemes;
if (!search.trim()) return colorDarkThemes;
const filteredThemes = useMemo(() => {
if (mode !== "theme") return colorThemes;
if (!search.trim()) return colorThemes;
const s = search.toLowerCase();
return colorDarkThemes.filter(
return colorThemes.filter(
(t) =>
t.name.toLowerCase().includes(s) ||
t.description.toLowerCase().includes(s),
);
}, [mode, search, colorDarkThemes]);
const filteredLightThemes = useMemo(() => {
if (mode !== "theme") return colorLightThemes;
if (!search.trim()) return colorLightThemes;
const s = search.toLowerCase();
return colorLightThemes.filter(
(t) =>
t.name.toLowerCase().includes(s) ||
t.description.toLowerCase().includes(s),
);
}, [mode, search, colorLightThemes]);
const filteredThemes = useMemo(
() => [...filteredDarkThemes, ...filteredLightThemes],
[filteredDarkThemes, filteredLightThemes],
);
}, [mode, search, colorThemes]);
const themeItems = useMemo(() => {
return filteredThemes.map((t) => ({
@@ -1161,27 +1114,6 @@ export function CommandMenu() {
}));
}, [filteredThemes, setColorTheme]);
// --- Code theme mode items ---
const filteredCodeThemes = useMemo(() => {
if (mode !== "code-theme") return BUILT_IN_THEMES;
if (!search.trim()) return BUILT_IN_THEMES;
const s = search.toLowerCase();
return BUILT_IN_THEMES.filter(
(t) => t.name.toLowerCase().includes(s) || t.id.toLowerCase().includes(s),
);
}, [mode, search]);
const codeThemeItems = useMemo(() => {
return filteredCodeThemes.map((t) => ({
id: `code-theme-${t.id}`,
action: () => {
if (t.mode === "dark") setCodeThemeDark(t.id);
else setCodeThemeLight(t.id);
},
keepOpen: true,
}));
}, [filteredCodeThemes, setCodeThemeDark, setCodeThemeLight]);
// --- Accounts mode items ---
const handleSwitchAccount = useCallback(
async (accountId: string | null) => {
@@ -1320,11 +1252,6 @@ export function CommandMenu() {
const settingsItems = useMemo(() => {
const items: { id: string; action: () => void; keepOpen: boolean }[] = [
{ id: "settings-theme", action: () => switchMode("theme"), keepOpen: true },
{
id: "settings-code-theme",
action: () => switchMode("code-theme"),
keepOpen: true,
},
{ id: "settings-model", action: () => switchMode("model"), keepOpen: true },
{
id: "settings-accounts",
@@ -1396,15 +1323,13 @@ export function CommandMenu() {
? searchItems
: mode === "theme"
? themeItems
: mode === "code-theme"
? codeThemeItems
: mode === "accounts"
? accountItems
: mode === "settings"
? settingsItems
: mode === "model"
? modelItems
: fileItems;
: mode === "accounts"
? accountItems
: mode === "settings"
? settingsItems
: mode === "model"
? modelItems
: fileItems;
useEffect(() => {
setSelectedIndex(0);
@@ -1445,11 +1370,7 @@ export function CommandMenu() {
if (e.key === "Backspace" && mode !== "commands" && !search) {
e.preventDefault();
// Sub-modes go back to settings, others go to commands
switchMode(
mode === "model" || mode === "code-theme"
? "settings"
: "commands",
);
switchMode(mode === "model" ? "settings" : "commands");
return;
}
@@ -1530,8 +1451,6 @@ export function CommandMenu() {
<FileText className="size-4 text-muted-foreground/50 shrink-0" />
) : mode === "theme" ? (
<Palette className="size-4 text-muted-foreground/50 shrink-0" />
) : mode === "code-theme" ? (
<Code className="size-4 text-muted-foreground/50 shrink-0" />
) : mode === "accounts" ? (
<Users className="size-4 text-muted-foreground/50 shrink-0" />
) : mode === "settings" ? (
@@ -1561,18 +1480,15 @@ export function CommandMenu() {
"theme"
? "Search themes..."
: mode ===
"code-theme"
? "Search code themes..."
"accounts"
? "Filter accounts..."
: mode ===
"accounts"
? "Filter accounts..."
"settings"
? "Configuration..."
: mode ===
"settings"
? "Configuration..."
: mode ===
"model"
? "Search models..."
: "Type a command..."
"model"
? "Search models..."
: "Type a command..."
}
className="flex-1 bg-transparent text-foreground placeholder:text-muted-foreground py-3 text-sm outline-none"
/>
@@ -2104,18 +2020,53 @@ export function CommandMenu() {
) : mode === "theme" ? (
/* Theme mode */
<>
{filteredDarkThemes.length >
{/* Mode toggle at the top */}
<div className="px-3 py-2 border-b border-border/50">
<button
type="button"
className="flex items-center justify-between w-full px-2 py-1.5 rounded-md hover:bg-muted/50 transition-colors"
onClick={() =>
toggleMode()
}
>
<span className="flex items-center gap-2 text-sm text-muted-foreground">
{currentMode ===
"dark" ? (
<Moon className="size-3.5" />
) : (
<Sun className="size-3.5" />
)}
<span>
{currentMode ===
"dark"
? "Dark"
: "Light"}{" "}
mode
</span>
</span>
<span className="text-xs text-muted-foreground/60">
Click
to
toggle
</span>
</button>
</div>
{filteredThemes.length >
0 && (
<CommandGroup title="Dark Themes">
{filteredDarkThemes.map(
<CommandGroup title="Themes">
{filteredThemes.map(
(
theme,
) => {
const idx =
getNextIndex();
const isActive =
darkThemeId ===
currentThemeId ===
theme.id;
const variant =
theme[
currentMode
];
return (
<CommandItemButton
key={
@@ -2139,79 +2090,14 @@ export function CommandMenu() {
className="w-3 h-3 rounded-full border border-border/40"
style={{
backgroundColor:
theme.bgPreview,
variant.bgPreview,
}}
/>
<span
className="w-3 h-3 rounded-full border border-border/40"
style={{
backgroundColor:
theme.accentPreview,
}}
/>
</span>
<span className="text-[13px] text-foreground flex-1">
{
theme.name
}
</span>
<span className="text-[11px] text-muted-foreground hidden sm:block">
{
theme.description
}
</span>
{isActive && (
<Check className="size-3.5 text-success shrink-0" />
)}
</CommandItemButton>
);
},
)}
</CommandGroup>
)}
{filteredLightThemes.length >
0 && (
<CommandGroup title="Light Themes">
{filteredLightThemes.map(
(
theme,
) => {
const idx =
getNextIndex();
const isActive =
lightThemeId ===
theme.id;
return (
<CommandItemButton
key={
theme.id
}
index={
idx
}
selected={
selectedIndex ===
idx
}
onClick={() =>
setColorTheme(
theme.id,
)
}
>
<span className="flex items-center gap-1 shrink-0">
<span
className="w-3 h-3 rounded-full border border-border/40"
style={{
backgroundColor:
theme.bgPreview,
}}
/>
<span
className="w-3 h-3 rounded-full border border-border/40"
style={{
backgroundColor:
theme.accentPreview,
variant.accentPreview,
}}
/>
</span>
@@ -2249,169 +2135,6 @@ export function CommandMenu() {
</div>
)}
</>
) : mode === "code-theme" ? (
/* Code theme mode */
<>
{(() => {
const darkThemes =
filteredCodeThemes.filter(
(
t,
) =>
t.mode ===
"dark",
);
const lightThemes =
filteredCodeThemes.filter(
(
t,
) =>
t.mode ===
"light",
);
return (
<>
{darkThemes.length >
0 && (
<CommandGroup title="Dark">
{darkThemes.map(
(
theme,
) => {
const idx =
getNextIndex();
const isActive =
codeThemeDark ===
theme.id;
return (
<CommandItemButton
key={
theme.id
}
index={
idx
}
selected={
selectedIndex ===
idx
}
onClick={() =>
setCodeThemeDark(
theme.id,
)
}
>
<span className="flex items-center gap-1 shrink-0">
<span
className="w-3 h-3 rounded-full border border-border/40"
style={{
backgroundColor:
theme.bgColor,
}}
/>
<span
className="w-3 h-3 rounded-full border border-border/40"
style={{
backgroundColor:
theme.accentColor,
}}
/>
</span>
<span className="text-[13px] text-foreground flex-1">
{
theme.name
}
</span>
<Moon className="size-2.5 text-muted-foreground shrink-0" />
{isActive && (
<Check className="size-3.5 text-success shrink-0" />
)}
</CommandItemButton>
);
},
)}
</CommandGroup>
)}
{lightThemes.length >
0 && (
<CommandGroup title="Light">
{lightThemes.map(
(
theme,
) => {
const idx =
getNextIndex();
const isActive =
codeThemeLight ===
theme.id;
return (
<CommandItemButton
key={
theme.id
}
index={
idx
}
selected={
selectedIndex ===
idx
}
onClick={() =>
setCodeThemeLight(
theme.id,
)
}
>
<span className="flex items-center gap-1 shrink-0">
<span
className="w-3 h-3 rounded-full border border-border/40"
style={{
backgroundColor:
theme.bgColor,
}}
/>
<span
className="w-3 h-3 rounded-full border border-border/40"
style={{
backgroundColor:
theme.accentColor,
}}
/>
</span>
<span className="text-[13px] text-foreground flex-1">
{
theme.name
}
</span>
<Sun className="size-2.5 text-muted-foreground shrink-0" />
{isActive && (
<Check className="size-3.5 text-success shrink-0" />
)}
</CommandItemButton>
);
},
)}
</CommandGroup>
)}
</>
);
})()}
{hasQuery &&
filteredCodeThemes.length ===
0 && (
<div className="py-8 text-center text-sm text-muted-foreground/70">
No
code
themes
match
&quot;
{
search
}
&quot;
</div>
)}
</>
) : mode === "accounts" ? (
/* Accounts mode */
<>
@@ -2904,7 +2627,7 @@ export function CommandMenu() {
t,
) =>
t.id ===
colorTheme,
currentThemeId,
)
?.name ??
"Theme"}
@@ -2913,46 +2636,6 @@ export function CommandMenu() {
</CommandItemButton>
);
})()}
{(() => {
const idx =
getNextIndex();
const activeDarkTheme =
BUILT_IN_THEMES.find(
(
t,
) =>
t.id ===
codeThemeDark,
);
return (
<CommandItemButton
key="settings-code-theme"
index={
idx
}
selected={
selectedIndex ===
idx
}
onClick={() =>
switchMode(
"code-theme",
)
}
>
<Code className="size-3.5 text-muted-foreground/50 shrink-0" />
<span className="text-[13px] text-foreground flex-1">
Code
Theme
</span>
<span className="text-[11px] text-muted-foreground hidden sm:block">
{activeDarkTheme?.name ??
codeThemeDark}
</span>
<ChevronRight className="size-3 text-muted-foreground/30 shrink-0" />
</CommandItemButton>
);
})()}
{(() => {
const idx =
getNextIndex();
@@ -3,6 +3,7 @@ import Image from "next/image";
import { CheckCircle2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { MarkdownCopyHandler } from "@/components/shared/markdown-copy-handler";
import { ReactiveCodeBlocks } from "@/components/shared/reactive-code-blocks";
import { TimeAgo } from "@/components/ui/time-ago";
import { CollapsibleBody } from "@/components/issue/collapsible-body";
import { BotActivityGroup } from "@/components/pr/bot-activity-group";
@@ -183,10 +184,12 @@ function DescriptionBlock({ entry }: { entry: DescriptionEntry }) {
const renderedBody = entry.bodyHtml ? (
<MarkdownCopyHandler>
<div
className="ghmd"
dangerouslySetInnerHTML={{ __html: entry.bodyHtml }}
/>
<ReactiveCodeBlocks>
<div
className="ghmd"
dangerouslySetInnerHTML={{ __html: entry.bodyHtml }}
/>
</ReactiveCodeBlocks>
</MarkdownCopyHandler>
) : null;
@@ -254,10 +257,12 @@ function CommentBlock({ comment }: { comment: DiscussionComment }) {
const renderedBody = comment.bodyHtml ? (
<MarkdownCopyHandler>
<div
className="ghmd"
dangerouslySetInnerHTML={{ __html: comment.bodyHtml }}
/>
<ReactiveCodeBlocks>
<div
className="ghmd"
dangerouslySetInnerHTML={{ __html: comment.bodyHtml }}
/>
</ReactiveCodeBlocks>
</MarkdownCopyHandler>
) : null;
@@ -346,10 +351,12 @@ function CommentBlock({ comment }: { comment: DiscussionComment }) {
function ReplyBlock({ reply }: { reply: DiscussionReply }) {
const renderedBody = reply.bodyHtml ? (
<MarkdownCopyHandler>
<div
className="ghmd ghmd-sm"
dangerouslySetInnerHTML={{ __html: reply.bodyHtml }}
/>
<ReactiveCodeBlocks>
<div
className="ghmd ghmd-sm"
dangerouslySetInnerHTML={{ __html: reply.bodyHtml }}
/>
</ReactiveCodeBlocks>
</MarkdownCopyHandler>
) : null;
@@ -1,6 +1,7 @@
import Link from "next/link";
import Image from "next/image";
import { MarkdownCopyHandler } from "@/components/shared/markdown-copy-handler";
import { ReactiveCodeBlocks } from "@/components/shared/reactive-code-blocks";
import { cn } from "@/lib/utils";
import { TimeAgo } from "@/components/ui/time-ago";
import { BotActivityGroup } from "@/components/pr/bot-activity-group";
@@ -275,10 +276,12 @@ function ThreadEntry({
const renderedBody = entry.bodyHtml ? (
<MarkdownCopyHandler>
<div
className="ghmd"
dangerouslySetInnerHTML={{ __html: entry.bodyHtml }}
/>
<ReactiveCodeBlocks>
<div
className="ghmd"
dangerouslySetInnerHTML={{ __html: entry.bodyHtml }}
/>
</ReactiveCodeBlocks>
</MarkdownCopyHandler>
) : null;
@@ -502,10 +505,12 @@ function ThreadComment({
const renderedBody = entry.bodyHtml ? (
<MarkdownCopyHandler>
<div
className="ghmd ghmd-sm"
dangerouslySetInnerHTML={{ __html: entry.bodyHtml }}
/>
<ReactiveCodeBlocks>
<div
className="ghmd ghmd-sm"
dangerouslySetInnerHTML={{ __html: entry.bodyHtml }}
/>
</ReactiveCodeBlocks>
</MarkdownCopyHandler>
) : null;
@@ -20,6 +20,8 @@ import { useGlobalChat, type InlineContext } from "@/components/shared/global-ch
import { CommitDialog } from "@/components/shared/commit-dialog";
import { commitFileEdit } from "@/app/(app)/repos/[owner]/[repo]/blob/blob-actions";
import { useMutationEvents } from "@/components/shared/mutation-event-provider";
import { useColorTheme } from "@/components/theme/theme-provider";
import { highlightCodeClient } from "@/lib/shiki-client";
interface CodeViewerClientProps {
html: string;
@@ -114,6 +116,32 @@ export function CodeViewerClient({
branch,
}: CodeViewerClientProps) {
const { addCodeContext } = useGlobalChat();
const { themeId } = useColorTheme();
// Theme-reactive highlighting: use server HTML initially, re-highlight on theme change
const [currentHtml, setCurrentHtml] = useState(html);
const initialThemeRef = useRef<string | null>(null);
useEffect(() => {
if (initialThemeRef.current === null) {
initialThemeRef.current = themeId;
return;
}
if (themeId === initialThemeRef.current) return;
let cancelled = false;
highlightCodeClient(content, language, themeId).then((newHtml) => {
if (!cancelled) {
setCurrentHtml(newHtml);
initialThemeRef.current = themeId;
}
});
return () => {
cancelled = true;
};
}, [themeId, content, language]);
const codeRef = useRef<HTMLDivElement>(null);
const [toolbarPos, setToolbarPos] = useState<{ x: number; y: number } | null>(null);
const [selectedRange, setSelectedRange] = useState<{
@@ -169,7 +197,7 @@ export function CodeViewerClient({
lines.forEach((el, i) => {
el.id = `L${i + 1}`;
});
}, [html]);
}, [currentHtml]);
// Apply highlight classes when highlightedLines changes
useEffect(() => {
@@ -187,7 +215,7 @@ export function CodeViewerClient({
el.classList.remove("line-highlighted");
}
});
}, [highlightedLines, html]);
}, [highlightedLines, currentHtml]);
// Read hash on mount and scroll to line
useEffect(() => {
@@ -977,7 +1005,9 @@ export function CodeViewerClient({
>
<div
className="code-content"
dangerouslySetInnerHTML={{ __html: html }}
dangerouslySetInnerHTML={{
__html: currentHtml,
}}
/>
<button
onClick={() => {
+17 -12
View File
@@ -30,6 +30,7 @@ import { useMutationEvents } from "@/components/shared/mutation-event-provider";
import { isRepoEvent, type MutationEvent } from "@/lib/mutation-events";
import { useReadme } from "@/hooks/use-readme";
import { MarkdownCopyHandler } from "@/components/shared/markdown-copy-handler";
import { ReactiveCodeBlocks } from "@/components/shared/reactive-code-blocks";
import { ReadmeToolbar } from "@/components/repo/readme-toolbar";
import {
fetchReadmeMarkdown,
@@ -1065,12 +1066,14 @@ export function RepoOverview({
</div>
<div className="px-6 py-5">
<MarkdownCopyHandler>
<div
className="ghmd"
dangerouslySetInnerHTML={{
__html: readmeHtml,
}}
/>
<ReactiveCodeBlocks>
<div
className="ghmd"
dangerouslySetInnerHTML={{
__html: readmeHtml,
}}
/>
</ReactiveCodeBlocks>
</MarkdownCopyHandler>
</div>
</div>
@@ -1194,12 +1197,14 @@ export function RepoOverview({
</div>
<div className="px-6 py-5">
<MarkdownCopyHandler>
<div
className="ghmd"
dangerouslySetInnerHTML={{
__html: readmeHtml,
}}
/>
<ReactiveCodeBlocks>
<div
className="ghmd"
dangerouslySetInnerHTML={{
__html: readmeHtml,
}}
/>
</ReactiveCodeBlocks>
</MarkdownCopyHandler>
</div>
</div>
@@ -1,10 +1,11 @@
"use client";
import { ArrowLeft, ExternalLink, ShieldAlert, User } from "lucide-react";
import { ArrowLeft, ExternalLink, User } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import { cn } from "@/lib/utils";
import { TimeAgo } from "@/components/ui/time-ago";
import { ReactiveCodeBlocks } from "@/components/shared/reactive-code-blocks";
import type { SecurityAdvisoryDetail } from "@/lib/github";
function severityColor(severity: string | null): {
@@ -165,12 +166,14 @@ export function AdvisoryDetail({
<div className="px-4 py-2.5 border-b border-border text-[11px] font-mono uppercase tracking-wider text-muted-foreground/60">
Description
</div>
<div
className="ghmd px-4 py-4"
dangerouslySetInnerHTML={{
__html: descriptionHtml,
}}
/>
<ReactiveCodeBlocks>
<div
className="ghmd px-4 py-4"
dangerouslySetInnerHTML={{
__html: descriptionHtml,
}}
/>
</ReactiveCodeBlocks>
</section>
) : (
<section className="border border-border">
@@ -5,6 +5,7 @@ import { Bug, ExternalLink, FileText, KeyRound, ShieldAlert } from "lucide-react
import Link from "next/link";
import { cn } from "@/lib/utils";
import { TimeAgo } from "@/components/ui/time-ago";
import { ReactiveCodeBlocks } from "@/components/shared/reactive-code-blocks";
interface Advisory {
ghsaId: string;
@@ -587,10 +588,14 @@ function PolicySection({
{/* Scrollable content */}
{policyHtml ? (
<div className="overflow-y-auto min-h-0 flex-1 px-4 py-4">
<div
className="ghmd"
dangerouslySetInnerHTML={{ __html: policyHtml }}
/>
<ReactiveCodeBlocks>
<div
className="ghmd"
dangerouslySetInnerHTML={{
__html: policyHtml,
}}
/>
</ReactiveCodeBlocks>
</div>
) : (
<div className="px-4 py-16 text-center">
@@ -1,13 +1,5 @@
"use client";
import { useState, useRef } from "react";
import { Check, Moon, Sun, Upload, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useCodeTheme } from "@/components/theme/code-theme-provider";
import { BUILT_IN_THEMES } from "@/lib/code-themes/built-in";
import { CODE_FONTS } from "@/lib/code-themes/fonts";
import type { CodeThemeOption } from "@/lib/code-themes/types";
import { useMutationEvents } from "@/components/shared/mutation-event-provider";
import { HighlightedCodeBlock } from "@/components/shared/highlighted-code-block";
const SAMPLE_CODE = `function fibonacci(n: number): number {
@@ -18,244 +10,18 @@ const SAMPLE_CODE = `function fibonacci(n: number): number {
const result = fibonacci(10);
console.log("Result:", result);`;
interface CustomThemeItem {
id: string;
name: string;
mode: "dark" | "light";
bgColor: string;
fgColor: string;
accentColor: string;
}
export function EditorTab() {
const {
codeThemeDark,
codeThemeLight,
codeFont,
codeFontSize,
setCodeThemeDark,
setCodeThemeLight,
setCodeFont,
setCodeFontSize,
} = useCodeTheme();
const { emit } = useMutationEvents();
const [customThemes, setCustomThemes] = useState<CustomThemeItem[]>([]);
const [importError, setImportError] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const loadedRef = useRef(false);
// Load custom themes on mount
if (!loadedRef.current) {
loadedRef.current = true;
fetch("/api/code-themes")
.then((r) => (r.ok ? r.json() : []))
.then((themes: CustomThemeItem[]) => setCustomThemes(themes))
.catch(() => {});
}
const darkThemes = BUILT_IN_THEMES.filter((t) => t.mode === "dark");
const lightThemes = BUILT_IN_THEMES.filter((t) => t.mode === "light");
const customDark = customThemes.filter((t) => t.mode === "dark");
const customLight = customThemes.filter((t) => t.mode === "light");
async function handleImportFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setImportError(null);
setImporting(true);
try {
const text = await file.text();
const json = JSON.parse(text);
const res = await fetch("/api/code-themes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: json.name || file.name.replace(/\.json$/, ""),
themeJson: json,
}),
});
if (!res.ok) {
const err = await res
.json()
.catch(() => ({ error: "Import failed" }));
setImportError(err.error || "Import failed");
} else {
const theme = await res.json();
setCustomThemes((prev) => [theme, ...prev]);
emit({ type: "code-theme:created" });
}
} catch {
setImportError("Invalid JSON file");
} finally {
setImporting(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
async function handleDeleteCustom(id: string) {
const res = await fetch("/api/code-themes", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id }),
});
if (res.ok) {
setCustomThemes((prev) => prev.filter((t) => t.id !== id));
// Reset selection if deleted theme was active
if (codeThemeDark === id) setCodeThemeDark("vitesse-black");
if (codeThemeLight === id) setCodeThemeLight("vitesse-light");
emit({ type: "code-theme:deleted" });
}
}
return (
<div className="divide-y divide-border overflow-y-auto max-h-[calc(100dvh-220px)]">
{/* Dark Code Theme */}
{/* Syntax Highlighting Info */}
<div className="px-4 py-4">
<label className="text-[11px] font-mono uppercase tracking-wider text-muted-foreground">
Dark Code Theme
Syntax Highlighting
</label>
<p className="text-[11px] text-muted-foreground/60 mt-0.5 mb-3">
Syntax highlighting theme used when the UI is in dark mode.
Code syntax highlighting is derived from your app theme.
Change your theme to change the code colors.
</p>
<ThemeGrid
themes={[...darkThemes, ...customDark.map(toOption)]}
activeId={codeThemeDark}
onSelect={setCodeThemeDark}
onDelete={(id) =>
customDark.some((c) => c.id === id) &&
handleDeleteCustom(id)
}
customIds={new Set(customDark.map((c) => c.id))}
/>
</div>
{/* Light Code Theme */}
<div className="px-4 py-4">
<label className="text-[11px] font-mono uppercase tracking-wider text-muted-foreground">
Light Code Theme
</label>
<p className="text-[11px] text-muted-foreground/60 mt-0.5 mb-3">
Syntax highlighting theme used when the UI is in light mode.
</p>
<ThemeGrid
themes={[...lightThemes, ...customLight.map(toOption)]}
activeId={codeThemeLight}
onSelect={setCodeThemeLight}
onDelete={(id) =>
customLight.some((c) => c.id === id) &&
handleDeleteCustom(id)
}
customIds={new Set(customLight.map((c) => c.id))}
/>
</div>
{/* Import Custom Theme */}
<div className="px-4 py-4">
<label className="text-[11px] font-mono uppercase tracking-wider text-muted-foreground">
Import Custom Theme
</label>
<p className="text-[11px] text-muted-foreground/60 mt-0.5 mb-3">
Import a VS Code compatible theme JSON file.
</p>
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleImportFile}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={importing}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-mono border border-border",
"hover:bg-muted/50 transition-colors cursor-pointer",
importing &&
"opacity-50 cursor-not-allowed",
)}
>
<Upload className="size-3" />
{importing ? "Importing..." : "Choose File"}
</button>
</div>
{importError && (
<p className="text-[11px] text-destructive mt-2">
{importError}
</p>
)}
</div>
{/* Code Font */}
<div className="px-4 py-4">
<label className="text-[11px] font-mono uppercase tracking-wider text-muted-foreground">
Code Font
</label>
<p className="text-[11px] text-muted-foreground/60 mt-0.5 mb-3">
Font used in code blocks, file viewer, and diffs.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{CODE_FONTS.map((font) => {
const isActive = codeFont === font.id;
return (
<button
key={font.id}
onClick={() => setCodeFont(font.id)}
className={cn(
"group relative flex flex-col border px-3 py-2.5 text-left transition-colors cursor-pointer",
isActive
? "border-foreground/30 bg-muted/50 dark:bg-white/[0.04]"
: "border-border hover:border-foreground/10 hover:bg-muted/30",
)}
>
<div className="flex items-center justify-between">
<span className="text-xs font-mono font-medium text-foreground">
{font.name}
</span>
{isActive && (
<Check className="size-3.5 text-success shrink-0" />
)}
</div>
<span
className="text-[11px] text-muted-foreground mt-1 truncate"
style={{
fontFamily: font.family,
}}
>
{`const x = 42; // preview`}
</span>
</button>
);
})}
</div>
</div>
{/* Font Size */}
<div className="px-4 py-4">
<label className="text-[11px] font-mono uppercase tracking-wider text-muted-foreground">
Font Size
</label>
<p className="text-[11px] text-muted-foreground/60 mt-0.5 mb-3">
Code font size in pixels.
</p>
<div className="flex items-center gap-3">
<input
type="range"
min={10}
max={20}
step={1}
value={codeFontSize}
onChange={(e) =>
setCodeFontSize(Number(e.target.value))
}
className="flex-1 accent-foreground"
/>
<span className="text-xs font-mono text-muted-foreground w-8 text-right">
{codeFontSize}px
</span>
</div>
</div>
{/* Live Preview */}
@@ -266,15 +32,14 @@ export function EditorTab() {
<div
className="mt-2 border border-border overflow-hidden"
style={{
backgroundColor:
"var(--code-theme-bg, var(--code-bg))",
backgroundColor: "var(--code-bg)",
}}
>
<div
className="code-content p-3 overflow-x-auto [&_pre]:!m-0 [&_pre]:!p-0 [&_pre]:!bg-transparent [&_code]:!bg-transparent"
style={{
fontFamily: "var(--code-font-override, var(--font-code)), ui-monospace, monospace",
fontSize: `var(--code-font-size, 13px)`,
fontFamily: "var(--font-code), ui-monospace, monospace",
fontSize: "13px",
lineHeight: "20px",
}}
>
@@ -288,105 +53,3 @@ export function EditorTab() {
</div>
);
}
function toOption(ct: CustomThemeItem): CodeThemeOption {
return {
id: ct.id,
name: ct.name,
type: "custom",
mode: ct.mode,
bgColor: ct.bgColor,
fgColor: ct.fgColor,
accentColor: ct.accentColor,
};
}
function ThemeGrid({
themes,
activeId,
onSelect,
onDelete,
customIds,
}: {
themes: CodeThemeOption[];
activeId: string;
onSelect: (id: string) => void;
onDelete: (id: string) => void;
customIds: Set<string>;
}) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{themes.map((theme) => {
const isActive = activeId === theme.id;
const isCustom = customIds.has(theme.id);
return (
<button
key={theme.id}
onClick={() => onSelect(theme.id)}
className={cn(
"group relative flex items-center gap-3 border px-3 py-2.5 text-left transition-colors cursor-pointer",
isActive
? "border-foreground/30 bg-muted/50 dark:bg-white/[0.04]"
: "border-border hover:border-foreground/10 hover:bg-muted/30",
)}
>
{/* Color preview dots */}
<div className="flex items-center gap-1 shrink-0">
<span
className="w-4 h-4 rounded-full border border-border/60"
style={{
backgroundColor:
theme.bgColor,
}}
/>
<span
className="w-4 h-4 rounded-full border border-border/60"
style={{
backgroundColor:
theme.accentColor,
}}
/>
</div>
{/* Name */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-xs font-mono font-medium text-foreground truncate">
{theme.name}
</span>
{theme.mode === "dark" ? (
<Moon className="size-2.5 text-muted-foreground/50 shrink-0" />
) : (
<Sun className="size-2.5 text-muted-foreground/50 shrink-0" />
)}
</div>
{isCustom && (
<span className="text-[10px] text-muted-foreground/60">
Custom
</span>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
{isActive && (
<Check className="size-3.5 text-success" />
)}
{isCustom && (
<button
onClick={(e) => {
e.stopPropagation();
onDelete(theme.id);
}}
className="opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive transition-all cursor-pointer"
>
<Trash2 className="size-3" />
</button>
)}
</div>
</button>
);
})}
</div>
);
}
@@ -14,16 +14,19 @@ interface GeneralTabProps {
function ThemeGrid({
themes,
activeId,
mode,
onSelect,
}: {
themes: ThemeDefinition[];
activeId: string;
mode: "dark" | "light";
onSelect: (id: string) => void;
}) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{themes.map((theme) => {
const isActive = activeId === theme.id;
const variant = theme[mode];
return (
<button
key={theme.id}
@@ -35,25 +38,23 @@ function ThemeGrid({
: "border-border hover:border-foreground/10 hover:bg-muted/30",
)}
>
{/* Color preview dots */}
<div className="flex items-center gap-1 shrink-0">
<span
className="w-4 h-4 rounded-full border border-border/60"
style={{
backgroundColor:
theme.bgPreview,
variant.bgPreview,
}}
/>
<span
className="w-4 h-4 rounded-full border border-border/60"
style={{
backgroundColor:
theme.accentPreview,
variant.accentPreview,
}}
/>
</div>
{/* Name + description */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-xs font-mono font-medium text-foreground">
@@ -65,7 +66,6 @@ function ThemeGrid({
</span>
</div>
{/* Check */}
{isActive && (
<Check className="size-3.5 text-success shrink-0" />
)}
@@ -77,40 +77,71 @@ function ThemeGrid({
}
export function GeneralTab({ settings: _settings, onUpdate: _onUpdate }: GeneralTabProps) {
const { setColorTheme, darkThemes, lightThemes, darkThemeId, lightThemeId } =
useColorTheme();
const { themeId, mode, setTheme, toggleMode, themes } = useColorTheme();
return (
<div className="divide-y divide-border">
{/* Dark themes */}
{/* Mode toggle */}
<div className="px-4 py-4">
<label className="text-[11px] font-mono uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
<Moon className="size-3" />
Dark Theme
{mode === "dark" ? (
<Moon className="size-3" />
) : (
<Sun className="size-3" />
)}
Appearance Mode
</label>
<p className="text-[11px] text-muted-foreground/60 mt-0.5 mb-3">
Used when dark mode is active.
Toggle between dark and light mode.
</p>
<ThemeGrid
themes={darkThemes}
activeId={darkThemeId}
onSelect={setColorTheme}
/>
<div className="flex gap-2">
<button
onClick={() => mode === "light" && toggleMode()}
className={cn(
"flex items-center gap-2 px-3 py-2 border text-sm transition-colors",
mode === "dark"
? "border-foreground/30 bg-muted/50"
: "border-border hover:border-foreground/10",
)}
>
<Moon className="size-4" />
<span>Dark</span>
{mode === "dark" && (
<Check className="size-3.5 text-success" />
)}
</button>
<button
onClick={() => mode === "dark" && toggleMode()}
className={cn(
"flex items-center gap-2 px-3 py-2 border text-sm transition-colors",
mode === "light"
? "border-foreground/30 bg-muted/50"
: "border-border hover:border-foreground/10",
)}
>
<Sun className="size-4" />
<span>Light</span>
{mode === "light" && (
<Check className="size-3.5 text-success" />
)}
</button>
</div>
</div>
{/* Light themes */}
{/* Theme selection */}
<div className="px-4 py-4">
<label className="text-[11px] font-mono uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
<Sun className="size-3" />
Light Theme
<label className="text-[11px] font-mono uppercase tracking-wider text-muted-foreground">
Theme
</label>
<p className="text-[11px] text-muted-foreground/60 mt-0.5 mb-3">
Used when light mode is active.
Choose a color theme. Each theme has both dark and light
variants.
</p>
<ThemeGrid
themes={lightThemes}
activeId={lightThemeId}
onSelect={setColorTheme}
themes={themes}
activeId={themeId}
mode={mode}
onSelect={setTheme}
/>
</div>
</div>
@@ -1,28 +1,8 @@
"use client";
import { useEffect, useState, memo } from "react";
import type { Highlighter, BundledLanguage } from "shiki";
let highlighterInstance: Highlighter | null = null;
let highlighterPromise: Promise<Highlighter> | null = null;
function getClientHighlighter(): Promise<Highlighter> {
if (highlighterInstance) return Promise.resolve(highlighterInstance);
if (!highlighterPromise) {
highlighterPromise = import("shiki")
.then(({ createHighlighter }) =>
createHighlighter({
themes: ["vitesse-light", "vitesse-black"],
langs: [],
}),
)
.then((h) => {
highlighterInstance = h;
return h;
});
}
return highlighterPromise;
}
import { useColorTheme } from "@/components/theme/theme-provider";
import { highlightCodeClient } from "@/lib/shiki-client";
export const HighlightedCodeBlock = memo(function HighlightedCodeBlock({
code,
@@ -31,50 +11,18 @@ export const HighlightedCodeBlock = memo(function HighlightedCodeBlock({
code: string;
lang: string;
}) {
const { themeId } = useColorTheme();
const [html, setHtml] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const highlighter = await getClientHighlighter();
const loaded = highlighter.getLoadedLanguages();
let effectiveLang = lang;
if (!loaded.includes(lang)) {
try {
await highlighter.loadLanguage(
lang as BundledLanguage,
);
} catch {
effectiveLang = "text";
if (!loaded.includes("text")) {
try {
await highlighter.loadLanguage(
"text" as BundledLanguage,
);
} catch {}
}
}
}
if (!cancelled) {
const result = highlighter.codeToHtml(code, {
lang: effectiveLang,
themes: {
light: "vitesse-light",
dark: "vitesse-black",
},
defaultColor: false,
});
setHtml(result);
}
} catch {
// silently fall back to plain text
}
})();
highlightCodeClient(code, lang, themeId).then((result) => {
if (!cancelled) setHtml(result);
});
return () => {
cancelled = true;
};
}, [code, lang]);
}, [code, lang, themeId]);
if (html) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
@@ -59,6 +59,7 @@ const sanitizeSchema: typeof defaultSchema = {
import { highlightCode } from "@/lib/shiki";
import { toInternalUrl } from "@/lib/github-utils";
import { MarkdownCopyHandler } from "@/components/shared/markdown-copy-handler";
import { ReactiveCodeBlocks } from "@/components/shared/reactive-code-blocks";
interface RepoContext {
owner: string;
@@ -366,6 +367,15 @@ function escapeHtml(str: string): string {
.replace(/"/g, "&quot;");
}
function escapeDataAttr(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/\n/g, "&#10;");
}
function buildInstallTabsHtml(variants: PkgVariant[], id: number): string {
const name = `pkgtab-${id}`;
let html = '<div class="ghmd-pkg-tabs">';
@@ -425,12 +435,15 @@ export async function renderMarkdownToHtml(
const renderedBlocks = await Promise.all(
codeBlocks.map(async (block) => ({
id: block.id,
code: block.code,
lang: block.lang,
html: await highlightCode(block.code, block.lang),
})),
);
for (const block of renderedBlocks) {
html = html.replace(`<div data-code-block="${block.id}"></div>`, block.html);
const wrappedHtml = `<div class="ghmd-reactive-code" data-code="${escapeDataAttr(block.code)}" data-lang="${escapeHtml(block.lang)}">${block.html}</div>`;
html = html.replace(`<div data-code-block="${block.id}"></div>`, wrappedHtml);
}
for (const block of installBlocks) {
@@ -486,10 +499,12 @@ export async function MarkdownRenderer({
return (
<MarkdownCopyHandler>
<div
className={`ghmd ${className || ""}`}
dangerouslySetInnerHTML={{ __html: html }}
/>
<ReactiveCodeBlocks>
<div
className={`ghmd ${className || ""}`}
dangerouslySetInnerHTML={{ __html: html }}
/>
</ReactiveCodeBlocks>
</MarkdownCopyHandler>
);
}
@@ -0,0 +1,71 @@
"use client";
import { useEffect, useRef } from "react";
import { useColorTheme } from "@/components/theme/theme-provider";
import { highlightCodeClient } from "@/lib/shiki-client";
interface ReactiveCodeBlocksProps {
children: React.ReactNode;
}
export function ReactiveCodeBlocks({ children }: ReactiveCodeBlocksProps) {
const { themeId } = useColorTheme();
const containerRef = useRef<HTMLDivElement>(null);
const initialThemeRef = useRef<string | null>(null);
useEffect(() => {
if (!containerRef.current) return;
if (initialThemeRef.current === null) {
initialThemeRef.current = themeId;
return;
}
if (initialThemeRef.current === themeId) return;
initialThemeRef.current = themeId;
const codeWrappers = containerRef.current.querySelectorAll(".ghmd-reactive-code");
if (codeWrappers.length === 0) return;
let cancelled = false;
const rehighlight = async () => {
for (const wrapper of codeWrappers) {
if (cancelled) break;
const code = wrapper.getAttribute("data-code");
const lang = wrapper.getAttribute("data-lang") || "text";
if (!code) continue;
const decodedCode = code
.replace(/&#10;/g, "\n")
.replace(/&quot;/g, '"')
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&");
try {
const html = await highlightCodeClient(
decodedCode,
lang,
themeId,
);
if (!cancelled) {
wrapper.innerHTML = html;
}
} catch {
// Keep existing content on error
}
}
};
rehighlight();
return () => {
cancelled = true;
};
}, [themeId]);
return <div ref={containerRef}>{children}</div>;
}
@@ -1,249 +0,0 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import {
DEFAULT_CODE_THEME_LIGHT,
DEFAULT_CODE_THEME_DARK,
DEFAULT_CODE_FONT,
DEFAULT_CODE_FONT_SIZE,
} from "@/lib/code-themes/types";
import type { CodeThemePreferences } from "@/lib/code-themes/types";
import { BUILT_IN_THEMES } from "@/lib/code-themes/built-in";
import { CODE_FONTS } from "@/lib/code-themes/fonts";
import type { CodeFontOption } from "@/lib/code-themes/fonts";
const LS_KEY = "code-theme-prefs";
const COOKIE_KEY = "code-theme-prefs";
interface CodeThemeContextValue {
codeThemeDark: string;
codeThemeLight: string;
codeFont: string;
codeFontSize: number;
setCodeThemeDark: (id: string) => void;
setCodeThemeLight: (id: string) => void;
setCodeFont: (id: string) => void;
setCodeFontSize: (size: number) => void;
}
const Ctx = createContext<CodeThemeContextValue | null>(null);
export function useCodeTheme(): CodeThemeContextValue {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useCodeTheme must be used within CodeThemeProvider");
return ctx;
}
function readLocalPrefs(): CodeThemePreferences {
if (typeof window === "undefined") {
return {
codeThemeLight: DEFAULT_CODE_THEME_LIGHT,
codeThemeDark: DEFAULT_CODE_THEME_DARK,
codeFont: DEFAULT_CODE_FONT,
codeFontSize: DEFAULT_CODE_FONT_SIZE,
};
}
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw)
return {
codeThemeLight: DEFAULT_CODE_THEME_LIGHT,
codeThemeDark: DEFAULT_CODE_THEME_DARK,
codeFont: DEFAULT_CODE_FONT,
codeFontSize: DEFAULT_CODE_FONT_SIZE,
};
const p = JSON.parse(raw);
return {
codeThemeLight: p.light ?? DEFAULT_CODE_THEME_LIGHT,
codeThemeDark: p.dark ?? DEFAULT_CODE_THEME_DARK,
codeFont: p.fontId ?? DEFAULT_CODE_FONT,
codeFontSize: p.fontSize ?? DEFAULT_CODE_FONT_SIZE,
};
} catch {
return {
codeThemeLight: DEFAULT_CODE_THEME_LIGHT,
codeThemeDark: DEFAULT_CODE_THEME_DARK,
codeFont: DEFAULT_CODE_FONT,
codeFontSize: DEFAULT_CODE_FONT_SIZE,
};
}
}
function writeLocalPrefs(prefs: CodeThemePreferences) {
const bgDark = BUILT_IN_THEMES.find((t) => t.id === prefs.codeThemeDark)?.bgColor;
const bgLight = BUILT_IN_THEMES.find((t) => t.id === prefs.codeThemeLight)?.bgColor;
const fontDef = CODE_FONTS.find((f) => f.id === prefs.codeFont);
// Store extra fields for FOUC script
const val = JSON.stringify({
light: prefs.codeThemeLight,
dark: prefs.codeThemeDark,
fontId: prefs.codeFont,
fontSize: prefs.codeFontSize,
// For the FOUC prevention script — use the dark bg by default
bg: bgDark ?? bgLight,
font: fontDef?.family ?? "",
});
localStorage.setItem(LS_KEY, val);
}
function setCookie(prefs: CodeThemePreferences) {
const value = JSON.stringify({
light: prefs.codeThemeLight,
dark: prefs.codeThemeDark,
font: prefs.codeFont,
fontSize: prefs.codeFontSize,
});
document.cookie = `${COOKIE_KEY}=${encodeURIComponent(value)};path=/;max-age=${365 * 24 * 60 * 60};samesite=lax`;
}
function applyCSS(prefs: CodeThemePreferences) {
const d = document.documentElement;
const isDark = d.classList.contains("dark");
// Set code theme background based on active UI mode
const activeThemeId = isDark ? prefs.codeThemeDark : prefs.codeThemeLight;
const themeDef = BUILT_IN_THEMES.find((t) => t.id === activeThemeId);
if (themeDef) {
d.style.setProperty("--code-theme-bg", themeDef.bgColor);
} else {
d.style.removeProperty("--code-theme-bg");
}
// Font override
const fontDef = CODE_FONTS.find((f) => f.id === prefs.codeFont);
if (fontDef && prefs.codeFont !== "default") {
d.style.setProperty("--code-font-override", fontDef.family);
} else {
d.style.removeProperty("--code-font-override");
}
// Font size
d.style.setProperty("--code-font-size", `${prefs.codeFontSize}px`);
}
function loadGoogleFont(font: CodeFontOption | undefined) {
if (!font?.googleFontsUrl) return;
const id = `code-font-${font.id}`;
if (document.getElementById(id)) return;
const link = document.createElement("link");
link.id = id;
link.rel = "stylesheet";
link.href = font.googleFontsUrl;
document.head.appendChild(link);
}
export function CodeThemeProvider({ children }: { children: React.ReactNode }) {
const [prefs, setPrefs] = useState<CodeThemePreferences>(readLocalPrefs);
const syncedFromDb = useRef(false);
// Apply CSS vars whenever prefs change
useEffect(() => {
applyCSS(prefs);
const fontDef = CODE_FONTS.find((f) => f.id === prefs.codeFont);
loadGoogleFont(fontDef);
}, [prefs]);
// Re-apply when UI theme changes (dark/light toggle)
useEffect(() => {
let raf: number;
const observer = new MutationObserver(() => {
// Debounce with rAF to batch rapid class changes
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => applyCSS(prefs));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => {
observer.disconnect();
cancelAnimationFrame(raf);
};
}, [prefs]);
// Sync from DB on mount
useEffect(() => {
if (syncedFromDb.current) return;
syncedFromDb.current = true;
fetch("/api/user-settings")
.then((r) => (r.ok ? r.json() : null))
.then((settings) => {
if (!settings) return;
const dbPrefs: CodeThemePreferences = {
codeThemeLight:
settings.codeThemeLight ?? DEFAULT_CODE_THEME_LIGHT,
codeThemeDark:
settings.codeThemeDark ?? DEFAULT_CODE_THEME_DARK,
codeFont: settings.codeFont ?? DEFAULT_CODE_FONT,
codeFontSize:
settings.codeFontSize ?? DEFAULT_CODE_FONT_SIZE,
};
const local = readLocalPrefs();
const changed =
dbPrefs.codeThemeLight !== local.codeThemeLight ||
dbPrefs.codeThemeDark !== local.codeThemeDark ||
dbPrefs.codeFont !== local.codeFont ||
dbPrefs.codeFontSize !== local.codeFontSize;
if (changed) {
writeLocalPrefs(dbPrefs);
setCookie(dbPrefs);
setPrefs(dbPrefs);
}
})
.catch(() => {});
}, []);
const persistUpdate = useCallback((newPrefs: CodeThemePreferences) => {
writeLocalPrefs(newPrefs);
setCookie(newPrefs);
setPrefs(newPrefs);
// Persist to DB in background
fetch("/api/user-settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
codeThemeLight: newPrefs.codeThemeLight,
codeThemeDark: newPrefs.codeThemeDark,
codeFont: newPrefs.codeFont,
codeFontSize: newPrefs.codeFontSize,
}),
}).catch(() => {});
}, []);
const setCodeThemeDark = useCallback(
(id: string) => persistUpdate({ ...prefs, codeThemeDark: id }),
[prefs, persistUpdate],
);
const setCodeThemeLight = useCallback(
(id: string) => persistUpdate({ ...prefs, codeThemeLight: id }),
[prefs, persistUpdate],
);
const setCodeFont = useCallback(
(id: string) => persistUpdate({ ...prefs, codeFont: id }),
[prefs, persistUpdate],
);
const setCodeFontSize = useCallback(
(size: number) => persistUpdate({ ...prefs, codeFontSize: size }),
[prefs, persistUpdate],
);
return (
<Ctx.Provider
value={{
codeThemeDark: prefs.codeThemeDark,
codeThemeLight: prefs.codeThemeLight,
codeFont: prefs.codeFont,
codeFontSize: prefs.codeFontSize,
setCodeThemeDark,
setCodeThemeLight,
setCodeFont,
setCodeFontSize,
}}
>
{children}
</Ctx.Provider>
);
}
+89 -122
View File
@@ -6,32 +6,25 @@ import {
applyTheme,
getTheme,
listThemes,
listDarkThemes,
listLightThemes,
migrateLegacyThemeId,
STORAGE_KEY,
DARK_THEME_KEY,
LIGHT_THEME_KEY,
DARK_THEME_ID,
LIGHT_THEME_ID,
MODE_KEY,
DEFAULT_THEME_ID,
DEFAULT_MODE,
type ThemeDefinition,
} from "@/lib/themes";
interface ColorThemeContext {
/** Currently active theme id */
colorTheme: string;
/** Set a specific theme (also updates the dark/light preference for that mode) */
setColorTheme: (id: string) => void;
/** Toggle between dark and light mode (switches to the preferred theme for that mode). Pass a MouseEvent for a circular reveal from the click point. */
themeId: string;
/** Current mode (dark/light) */
mode: "dark" | "light";
/** Set a specific theme */
setTheme: (id: string) => void;
/** Toggle between dark and light mode */
toggleMode: (e?: { clientX: number; clientY: number }) => void;
/** All themes */
themes: ThemeDefinition[];
darkThemes: ThemeDefinition[];
lightThemes: ThemeDefinition[];
/** The preferred dark theme id */
darkThemeId: string;
/** The preferred light theme id */
lightThemeId: string;
mode: "dark" | "light";
}
const Ctx = createContext<ColorThemeContext | null>(null);
@@ -42,89 +35,83 @@ export function useColorTheme(): ColorThemeContext {
return ctx;
}
function getStored(key: string, fallback: string): string {
if (typeof window === "undefined") return fallback;
return localStorage.getItem(key) ?? fallback;
const THEME_COOKIE_KEY = "color-theme";
const MODE_COOKIE_KEY = "color-mode";
function setThemeCookies(themeId: string, mode: "dark" | "light") {
const maxAge = 365 * 24 * 60 * 60;
document.cookie = `${THEME_COOKIE_KEY}=${encodeURIComponent(themeId)};path=/;max-age=${maxAge};samesite=lax`;
document.cookie = `${MODE_COOKIE_KEY}=${mode};path=/;max-age=${maxAge};samesite=lax`;
}
/** Pick the initial active theme: localStorage → system preference → dark fallback */
function getInitialTheme(): string {
if (typeof window === "undefined") return DARK_THEME_ID;
const stored = localStorage.getItem(STORAGE_KEY);
// Trust the stored value - the inline script already validated it.
// This prevents hydration mismatches where getTheme() might not be ready.
if (stored) return stored;
function getStoredPreferences(): { themeId: string; mode: "dark" | "light" } {
if (typeof window === "undefined") {
return { themeId: DEFAULT_THEME_ID, mode: DEFAULT_MODE };
}
const storedTheme = localStorage.getItem(STORAGE_KEY);
const storedMode = localStorage.getItem(MODE_KEY) as "dark" | "light" | null;
if (storedTheme && storedMode && getTheme(storedTheme)) {
return { themeId: storedTheme, mode: storedMode };
}
if (storedTheme) {
const migration = migrateLegacyThemeId(storedTheme);
if (migration) {
localStorage.setItem(STORAGE_KEY, migration.themeId);
localStorage.setItem(MODE_KEY, migration.mode);
return migration;
}
if (getTheme(storedTheme)) {
const mode =
storedMode ??
(window.matchMedia?.("(prefers-color-scheme: dark)").matches
? "dark"
: "light");
localStorage.setItem(MODE_KEY, mode);
return { themeId: storedTheme, mode };
}
}
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? true;
const darkPref = localStorage.getItem(DARK_THEME_KEY) ?? DARK_THEME_ID;
const lightPref = localStorage.getItem(LIGHT_THEME_KEY) ?? LIGHT_THEME_ID;
const id = prefersDark ? darkPref : lightPref;
localStorage.setItem(STORAGE_KEY, id);
return id;
const mode = prefersDark ? "dark" : "light";
localStorage.setItem(STORAGE_KEY, DEFAULT_THEME_ID);
localStorage.setItem(MODE_KEY, mode);
return { themeId: DEFAULT_THEME_ID, mode };
}
export function ColorThemeProvider({ children }: { children: React.ReactNode }) {
const { setTheme } = useTheme();
const [colorTheme, setColorThemeState] = useState(getInitialTheme);
const [darkThemeId, setDarkThemeId] = useState(() =>
getStored(DARK_THEME_KEY, DARK_THEME_ID),
);
const [lightThemeId, setLightThemeId] = useState(() =>
getStored(LIGHT_THEME_KEY, LIGHT_THEME_ID),
);
const syncedFromDb = useRef(false);
const appliedRef = useRef<string | null>(null);
const { setTheme: setNextTheme } = useTheme();
const [themeId, setThemeIdState] = useState(DEFAULT_THEME_ID);
const [mode, setModeState] = useState<"dark" | "light">(DEFAULT_MODE);
const syncedToDb = useRef(false);
const themes = listThemes();
const darkThemes = listDarkThemes();
const lightThemes = listLightThemes();
const currentTheme = getTheme(colorTheme);
const mode = currentTheme?.mode ?? "dark";
// Apply CSS vars + dark/light class whenever colorTheme changes
useEffect(() => {
if (appliedRef.current === colorTheme) return;
appliedRef.current = colorTheme;
applyTheme(colorTheme);
const theme = getTheme(colorTheme);
if (theme) setTheme(theme.mode);
}, [colorTheme, setTheme]);
const prefs = getStoredPreferences();
setThemeIdState(prefs.themeId);
setModeState(prefs.mode);
// Mark the initial theme as already applied (FOUC script handled it)
useEffect(() => {
appliedRef.current = colorTheme;
applyTheme(prefs.themeId, prefs.mode);
setThemeCookies(prefs.themeId, prefs.mode);
setNextTheme(prefs.mode);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// On mount: fetch theme from DB — if it differs from localStorage, adopt it
useEffect(() => {
if (syncedFromDb.current) return;
syncedFromDb.current = true;
if (syncedToDb.current) return;
syncedToDb.current = true;
fetch("/api/user-settings")
.then((r) => (r.ok ? r.json() : null))
.then((settings) => {
if (!settings) return;
// Sync dark/light preferences from DB
if (settings.darkTheme && getTheme(settings.darkTheme)) {
localStorage.setItem(DARK_THEME_KEY, settings.darkTheme);
setDarkThemeId(settings.darkTheme);
}
if (settings.lightTheme && getTheme(settings.lightTheme)) {
localStorage.setItem(LIGHT_THEME_KEY, settings.lightTheme);
setLightThemeId(settings.lightTheme);
}
// Sync active theme from DB
if (settings.colorTheme && getTheme(settings.colorTheme)) {
const dbTheme = settings.colorTheme;
const local =
localStorage.getItem(STORAGE_KEY) ?? DARK_THEME_ID;
if (dbTheme !== local) {
localStorage.setItem(STORAGE_KEY, dbTheme);
setColorThemeState(dbTheme);
appliedRef.current = null;
}
}
})
.catch(() => {});
const prefs = getStoredPreferences();
fetch("/api/user-settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
colorTheme: prefs.themeId,
colorMode: prefs.mode,
}),
}).catch(() => {});
}, []);
const applyWithTransition = useCallback(
@@ -152,77 +139,57 @@ export function ColorThemeProvider({ children }: { children: React.ReactNode })
[],
);
const setColorTheme = useCallback(
const setTheme = useCallback(
(id: string) => {
const theme = getTheme(id);
if (!theme) return;
applyWithTransition(() => {
// Update the mode-specific preference
if (theme.mode === "dark") {
localStorage.setItem(DARK_THEME_KEY, id);
setDarkThemeId(id);
} else {
localStorage.setItem(LIGHT_THEME_KEY, id);
setLightThemeId(id);
}
localStorage.setItem(STORAGE_KEY, id);
appliedRef.current = null;
setColorThemeState(id);
setThemeIdState(id);
applyTheme(id, mode);
setThemeCookies(id, mode);
});
// Persist to DB in background
fetch("/api/user-settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ colorTheme: id }),
body: JSON.stringify({ colorTheme: id, colorMode: mode }),
}).catch(() => {});
},
[applyWithTransition],
[mode, applyWithTransition],
);
const toggleMode = useCallback(
(e?: { clientX: number; clientY: number }) => {
const nextId = mode === "dark" ? lightThemeId : darkThemeId;
const theme = getTheme(nextId);
if (!theme) return;
const nextMode = mode === "dark" ? "light" : "dark";
const coords = e ? { x: e.clientX, y: e.clientY } : undefined;
applyWithTransition(() => {
if (theme.mode === "dark") {
localStorage.setItem(DARK_THEME_KEY, nextId);
setDarkThemeId(nextId);
} else {
localStorage.setItem(LIGHT_THEME_KEY, nextId);
setLightThemeId(nextId);
}
localStorage.setItem(STORAGE_KEY, nextId);
appliedRef.current = null;
setColorThemeState(nextId);
localStorage.setItem(MODE_KEY, nextMode);
setModeState(nextMode);
applyTheme(themeId, nextMode);
setThemeCookies(themeId, nextMode);
setNextTheme(nextMode);
}, coords);
fetch("/api/user-settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ colorTheme: nextId }),
body: JSON.stringify({ colorTheme: themeId, colorMode: nextMode }),
}).catch(() => {});
},
[mode, darkThemeId, lightThemeId, applyWithTransition],
[mode, themeId, applyWithTransition, setNextTheme],
);
return (
<Ctx.Provider
value={{
colorTheme,
setColorTheme,
themeId,
mode,
setTheme,
toggleMode,
themes,
darkThemes,
lightThemes,
darkThemeId,
lightThemeId,
mode,
}}
>
{children}
+8 -6
View File
@@ -8,19 +8,21 @@ import { useColorTheme } from "./theme-provider";
* Renders nothing — just a side-effect component.
*/
export function ThemeSync() {
const { colorTheme } = useColorTheme();
const prevRef = useRef(colorTheme);
const { themeId, mode } = useColorTheme();
const prevThemeRef = useRef(themeId);
const prevModeRef = useRef(mode);
useEffect(() => {
if (colorTheme === prevRef.current) return;
prevRef.current = colorTheme;
if (themeId === prevThemeRef.current && mode === prevModeRef.current) return;
prevThemeRef.current = themeId;
prevModeRef.current = mode;
fetch("/api/user-settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ colorTheme }),
body: JSON.stringify({ colorTheme: themeId, colorMode: mode }),
}).catch(() => {});
}, [colorTheme]);
}, [themeId, mode]);
return null;
}
-168
View File
@@ -1,168 +0,0 @@
import type { CodeThemeOption } from "./types";
export const BUILT_IN_THEMES: CodeThemeOption[] = [
// ── Dark themes ──
{
id: "vitesse-black",
name: "Vitesse Black",
type: "built-in",
mode: "dark",
bgColor: "#000000",
fgColor: "#dbd7ca",
accentColor: "#4d9375",
},
{
id: "github-dark",
name: "GitHub Dark",
type: "built-in",
mode: "dark",
bgColor: "#0d1117",
fgColor: "#e6edf3",
accentColor: "#79c0ff",
},
{
id: "github-dark-dimmed",
name: "GitHub Dimmed",
type: "built-in",
mode: "dark",
bgColor: "#22272e",
fgColor: "#adbac7",
accentColor: "#6cb6ff",
},
{
id: "dracula",
name: "Dracula",
type: "built-in",
mode: "dark",
bgColor: "#282a36",
fgColor: "#f8f8f2",
accentColor: "#bd93f9",
},
{
id: "one-dark-pro",
name: "One Dark Pro",
type: "built-in",
mode: "dark",
bgColor: "#282c34",
fgColor: "#abb2bf",
accentColor: "#c678dd",
},
{
id: "tokyo-night",
name: "Tokyo Night",
type: "built-in",
mode: "dark",
bgColor: "#1a1b26",
fgColor: "#a9b1d6",
accentColor: "#7aa2f7",
},
{
id: "nord",
name: "Nord",
type: "built-in",
mode: "dark",
bgColor: "#2e3440",
fgColor: "#d8dee9",
accentColor: "#88c0d0",
},
{
id: "catppuccin-mocha",
name: "Catppuccin Mocha",
type: "built-in",
mode: "dark",
bgColor: "#1e1e2e",
fgColor: "#cdd6f4",
accentColor: "#cba6f7",
},
{
id: "ayu-dark",
name: "Ayu Dark",
type: "built-in",
mode: "dark",
bgColor: "#0b0e14",
fgColor: "#bfbdb6",
accentColor: "#e6b450",
},
{
id: "monokai",
name: "Monokai",
type: "built-in",
mode: "dark",
bgColor: "#272822",
fgColor: "#f8f8f2",
accentColor: "#f92672",
},
{
id: "solarized-dark",
name: "Solarized Dark",
type: "built-in",
mode: "dark",
bgColor: "#002b36",
fgColor: "#839496",
accentColor: "#268bd2",
},
// ── Light themes ──
{
id: "vitesse-light",
name: "Vitesse Light",
type: "built-in",
mode: "light",
bgColor: "#ffffff",
fgColor: "#393a34",
accentColor: "#1e754f",
},
{
id: "github-light",
name: "GitHub Light",
type: "built-in",
mode: "light",
bgColor: "#ffffff",
fgColor: "#1f2328",
accentColor: "#0550ae",
},
{
id: "one-light",
name: "One Light",
type: "built-in",
mode: "light",
bgColor: "#fafafa",
fgColor: "#383a42",
accentColor: "#a626a4",
},
{
id: "catppuccin-latte",
name: "Catppuccin Latte",
type: "built-in",
mode: "light",
bgColor: "#eff1f5",
fgColor: "#4c4f69",
accentColor: "#8839ef",
},
{
id: "solarized-light",
name: "Solarized Light",
type: "built-in",
mode: "light",
bgColor: "#fdf6e3",
fgColor: "#657b83",
accentColor: "#268bd2",
},
{
id: "min-light",
name: "Min Light",
type: "built-in",
mode: "light",
bgColor: "#ffffff",
fgColor: "#24292eff",
accentColor: "#1a1a1a",
},
];
export function getBuiltInTheme(id: string): CodeThemeOption | undefined {
return BUILT_IN_THEMES.find((t) => t.id === id);
}
export function getBuiltInThemesByMode(mode: "dark" | "light"): CodeThemeOption[] {
return BUILT_IN_THEMES.filter((t) => t.mode === mode);
}
-38
View File
@@ -1,38 +0,0 @@
import { cookies } from "next/headers";
import { type CodeThemePreferences, DEFAULT_CODE_THEME_PREFS } from "./types";
export const CODE_THEME_COOKIE = "code-theme-prefs";
/**
* Server-side: read code theme preferences from cookie.
* Returns defaults if cookie is missing or malformed.
*/
export async function getCodeThemePrefs(): Promise<CodeThemePreferences> {
try {
const cookieStore = await cookies();
const raw = cookieStore.get(CODE_THEME_COOKIE)?.value;
if (!raw) return DEFAULT_CODE_THEME_PREFS;
const parsed = JSON.parse(raw);
return {
codeThemeLight: parsed.light ?? DEFAULT_CODE_THEME_PREFS.codeThemeLight,
codeThemeDark: parsed.dark ?? DEFAULT_CODE_THEME_PREFS.codeThemeDark,
codeFont: parsed.font ?? DEFAULT_CODE_THEME_PREFS.codeFont,
codeFontSize: parsed.fontSize ?? DEFAULT_CODE_THEME_PREFS.codeFontSize,
};
} catch {
return DEFAULT_CODE_THEME_PREFS;
}
}
/**
* Client-side: set the code theme prefs cookie.
*/
export function setCodeThemeCookie(prefs: CodeThemePreferences): void {
const value = JSON.stringify({
light: prefs.codeThemeLight,
dark: prefs.codeThemeDark,
font: prefs.codeFont,
fontSize: prefs.codeFontSize,
});
document.cookie = `${CODE_THEME_COOKIE}=${encodeURIComponent(value)};path=/;max-age=${365 * 24 * 60 * 60};samesite=lax`;
}
-49
View File
@@ -1,49 +0,0 @@
export interface CodeFontOption {
id: string;
name: string;
family: string;
googleFontsUrl?: string;
}
export const CODE_FONTS: CodeFontOption[] = [
{
id: "default",
name: "JetBrains Mono",
family: "var(--font-code), ui-monospace, monospace",
},
{
id: "geist-mono",
name: "Geist Mono",
family: "var(--font-geist-mono), ui-monospace, monospace",
},
{
id: "fira-code",
name: "Fira Code",
family: "'Fira Code', ui-monospace, monospace",
googleFontsUrl:
"https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600&display=swap",
},
{
id: "source-code-pro",
name: "Source Code Pro",
family: "'Source Code Pro', ui-monospace, monospace",
googleFontsUrl:
"https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@400;500;600&display=swap",
},
{
id: "ibm-plex-mono",
name: "IBM Plex Mono",
family: "'IBM Plex Mono', ui-monospace, monospace",
googleFontsUrl:
"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap",
},
{
id: "sf-mono",
name: "SF Mono (System)",
family: "'SF Mono', 'Menlo', 'Monaco', 'Consolas', ui-monospace, monospace",
},
];
export function getCodeFont(id: string): CodeFontOption | undefined {
return CODE_FONTS.find((f) => f.id === id);
}
-49
View File
@@ -1,49 +0,0 @@
export type { CodeThemeOption, CustomCodeTheme, CodeThemePreferences } from "./types";
export {
DEFAULT_CODE_THEME_LIGHT,
DEFAULT_CODE_THEME_DARK,
DEFAULT_CODE_FONT,
DEFAULT_CODE_FONT_SIZE,
DEFAULT_CODE_THEME_PREFS,
} from "./types";
export { BUILT_IN_THEMES, getBuiltInTheme, getBuiltInThemesByMode } from "./built-in";
export { CODE_FONTS, getCodeFont } from "./fonts";
export type { CodeFontOption } from "./fonts";
export {
getCustomThemes,
getCustomTheme,
saveCustomTheme,
deleteCustomTheme,
extractColorsFromVSCodeTheme,
} from "./store";
export { CODE_THEME_COOKIE, getCodeThemePrefs, setCodeThemeCookie } from "./cookie";
import { BUILT_IN_THEMES } from "./built-in";
import { getCustomThemes } from "./store";
import type { CodeThemeOption } from "./types";
/**
* Get all available themes (built-in + user's custom), optionally filtered by mode.
*/
export async function getAvailableThemes(
userId: string,
mode?: "dark" | "light",
): Promise<CodeThemeOption[]> {
const custom = (await getCustomThemes(userId)).map(
(ct): CodeThemeOption => ({
id: ct.id,
name: ct.name,
type: "custom",
mode: ct.mode,
bgColor: ct.bgColor,
fgColor: ct.fgColor,
accentColor: ct.accentColor,
}),
);
const all = [...BUILT_IN_THEMES, ...custom];
return mode ? all.filter((t) => t.mode === mode) : all;
}
-122
View File
@@ -1,122 +0,0 @@
import { prisma } from "../db";
import type { CustomCodeTheme } from "./types";
function toTheme(row: {
id: string;
userId: string;
name: string;
mode: string;
themeJson: string;
bgColor: string;
fgColor: string;
accentColor: string;
createdAt: string;
}): CustomCodeTheme {
return {
id: row.id,
userId: row.userId,
name: row.name,
mode: row.mode as "dark" | "light",
themeJson: row.themeJson,
bgColor: row.bgColor,
fgColor: row.fgColor,
accentColor: row.accentColor,
createdAt: row.createdAt,
};
}
export async function getCustomThemes(userId: string): Promise<CustomCodeTheme[]> {
const rows = await prisma.customCodeTheme.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
});
return rows.map(toTheme);
}
export async function getCustomTheme(id: string): Promise<CustomCodeTheme | null> {
const row = await prisma.customCodeTheme.findUnique({ where: { id } });
return row ? toTheme(row) : null;
}
export async function saveCustomTheme(
userId: string,
name: string,
mode: "dark" | "light",
themeJson: string,
bgColor: string,
fgColor: string,
accentColor: string,
): Promise<CustomCodeTheme> {
const id = crypto.randomUUID();
const now = new Date().toISOString();
const created = await prisma.customCodeTheme.create({
data: {
id,
userId,
name,
mode,
themeJson,
bgColor,
fgColor,
accentColor,
createdAt: now,
},
});
return toTheme(created);
}
export async function deleteCustomTheme(id: string, userId: string): Promise<boolean> {
const result = await prisma.customCodeTheme.deleteMany({
where: { id, userId },
});
return result.count > 0;
}
/**
* Extract editor colors from a VS Code theme JSON.
* Returns { bg, fg, accent } from editor colors and tokenColors.
*/
export function extractColorsFromVSCodeTheme(json: Record<string, unknown>): {
bg: string;
fg: string;
accent: string;
mode: "dark" | "light";
} {
const colors = (json.colors ?? {}) as Record<string, string>;
const bg = colors["editor.background"] ?? "#1e1e1e";
const fg = colors["editor.foreground"] ?? "#d4d4d4";
let accent = fg;
const tokenColors = json.tokenColors as
| Array<{
scope?: string | string[];
settings?: { foreground?: string };
}>
| undefined;
if (Array.isArray(tokenColors)) {
for (const tc of tokenColors) {
const scopes = Array.isArray(tc.scope) ? tc.scope : [tc.scope];
if (scopes.some((s) => s === "keyword" || s === "keyword.control")) {
accent = tc.settings?.foreground ?? accent;
break;
}
}
}
const mode = isColorDark(bg) ? "dark" : "light";
return { bg, fg, accent, mode };
}
function isColorDark(hex: string): boolean {
const c = hex.replace("#", "");
if (c.length < 6) return true;
const r = parseInt(c.slice(0, 2), 16);
const g = parseInt(c.slice(2, 4), 16);
const b = parseInt(c.slice(4, 6), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance < 0.5;
}
-40
View File
@@ -1,40 +0,0 @@
export interface CodeThemeOption {
id: string;
name: string;
type: "built-in" | "custom";
mode: "dark" | "light";
bgColor: string;
fgColor: string;
accentColor: string;
}
export interface CustomCodeTheme {
id: string;
userId: string;
name: string;
mode: "dark" | "light";
themeJson: string;
bgColor: string;
fgColor: string;
accentColor: string;
createdAt: string;
}
export interface CodeThemePreferences {
codeThemeLight: string;
codeThemeDark: string;
codeFont: string;
codeFontSize: number;
}
export const DEFAULT_CODE_THEME_LIGHT = "vitesse-light";
export const DEFAULT_CODE_THEME_DARK = "vitesse-black";
export const DEFAULT_CODE_FONT = "default";
export const DEFAULT_CODE_FONT_SIZE = 13;
export const DEFAULT_CODE_THEME_PREFS: CodeThemePreferences = {
codeThemeLight: DEFAULT_CODE_THEME_LIGHT,
codeThemeDark: DEFAULT_CODE_THEME_DARK,
codeFont: DEFAULT_CODE_FONT,
codeFontSize: DEFAULT_CODE_FONT_SIZE,
};
-4
View File
@@ -133,8 +133,6 @@ export type PinRemovedEvent = { type: "pin:removed"; owner: string; repo: string
// ── Settings Events ───────────────────────────────────────────
export type SettingsUpdatedEvent = { type: "settings:updated" };
export type CodeThemeCreatedEvent = { type: "code-theme:created" };
export type CodeThemeDeletedEvent = { type: "code-theme:deleted" };
export type GitHubAccountAddedEvent = { type: "github-account:added" };
export type GitHubAccountRemovedEvent = { type: "github-account:removed" };
export type GitHubAccountSwitchedEvent = { type: "github-account:switched" };
@@ -175,8 +173,6 @@ export type MutationEvent =
| PinAddedEvent
| PinRemovedEvent
| SettingsUpdatedEvent
| CodeThemeCreatedEvent
| CodeThemeDeletedEvent
| GitHubAccountAddedEvent
| GitHubAccountRemovedEvent
| GitHubAccountSwitchedEvent;
+183
View File
@@ -0,0 +1,183 @@
import type { Highlighter, BundledLanguage } from "shiki";
import { getTheme } from "./themes";
import type { ShikiTheme } from "./themes/types";
const DEFAULT_LIGHT_THEME = "vitesse-light";
const DEFAULT_DARK_THEME = "vitesse-black";
const MAX_TOKENIZE_LENGTH = 200_000;
let highlighterInstance: Highlighter | null = null;
let highlighterPromise: Promise<Highlighter> | null = null;
function getClientHighlighter(): Promise<Highlighter> {
if (highlighterInstance) return Promise.resolve(highlighterInstance);
if (!highlighterPromise) {
highlighterPromise = import("shiki")
.then(({ createHighlighter }) =>
createHighlighter({
themes: [DEFAULT_LIGHT_THEME, DEFAULT_DARK_THEME],
langs: [],
}),
)
.then((h) => {
highlighterInstance = h;
return h;
});
}
return highlighterPromise;
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
async function loadCustomSyntaxTheme(
highlighter: Highlighter,
theme: ShikiTheme,
uniqueName: string,
): Promise<string> {
const loaded = highlighter.getLoadedThemes();
if (loaded.includes(uniqueName)) return uniqueName;
try {
const themeWithName = { ...theme, name: uniqueName };
await highlighter.loadTheme(themeWithName);
return uniqueName;
} catch {
return "";
}
}
async function loadThemeWithCustomBg(
highlighter: Highlighter,
baseThemeId: string,
bgColor: string,
uniqueName: string,
): Promise<string> {
const loaded = highlighter.getLoadedThemes();
if (loaded.includes(uniqueName)) return uniqueName;
try {
if (!loaded.includes(baseThemeId)) {
await highlighter.loadTheme(
baseThemeId as Parameters<Highlighter["loadTheme"]>[0],
);
}
const baseTheme = highlighter.getTheme(baseThemeId);
const modifiedTheme = {
...baseTheme,
name: uniqueName,
colors: {
...baseTheme.colors,
"editor.background": bgColor,
},
};
await highlighter.loadTheme(modifiedTheme);
return uniqueName;
} catch {
return baseThemeId;
}
}
async function getThemePairForClient(
highlighter: Highlighter,
themeId: string,
): Promise<{ light: string; dark: string }> {
const appTheme = getTheme(themeId);
let light = DEFAULT_LIGHT_THEME;
let dark = DEFAULT_DARK_THEME;
if (appTheme) {
if (appTheme.light.syntax) {
const customLightName = `${appTheme.id}-syntax-light`;
const loadedName = await loadCustomSyntaxTheme(
highlighter,
appTheme.light.syntax,
customLightName,
);
if (loadedName) light = loadedName;
} else {
const codeBg = appTheme.light.colors["--code-bg"];
if (codeBg) {
const customName = `${appTheme.id}-light-vitesse`;
light = await loadThemeWithCustomBg(
highlighter,
DEFAULT_LIGHT_THEME,
codeBg,
customName,
);
}
}
if (appTheme.dark.syntax) {
const customDarkName = `${appTheme.id}-syntax-dark`;
const loadedName = await loadCustomSyntaxTheme(
highlighter,
appTheme.dark.syntax,
customDarkName,
);
if (loadedName) dark = loadedName;
} else {
const codeBg = appTheme.dark.colors["--code-bg"];
if (codeBg) {
const customName = `${appTheme.id}-dark-vitesse`;
dark = await loadThemeWithCustomBg(
highlighter,
DEFAULT_DARK_THEME,
codeBg,
customName,
);
}
}
}
return { light, dark };
}
export async function highlightCodeClient(
code: string,
lang: string,
themeId: string,
): Promise<string> {
if (code.length > MAX_TOKENIZE_LENGTH) {
return `<pre><code>${escapeHtml(code)}</code></pre>`;
}
const highlighter = await getClientHighlighter();
const themes = await getThemePairForClient(highlighter, themeId);
const loaded = highlighter.getLoadedLanguages();
let effectiveLang = lang || "text";
if (!loaded.includes(effectiveLang)) {
try {
await highlighter.loadLanguage(effectiveLang as BundledLanguage);
} catch {
effectiveLang = "text";
if (!loaded.includes("text")) {
try {
await highlighter.loadLanguage("text" as BundledLanguage);
} catch {}
}
}
}
try {
return highlighter.codeToHtml(code, {
lang: effectiveLang,
themes: { light: themes.light, dark: themes.dark },
defaultColor: false,
});
} catch {
return highlighter.codeToHtml(code, {
lang: "text",
themes: { light: themes.light, dark: themes.dark },
defaultColor: false,
});
}
}
+142 -52
View File
@@ -5,11 +5,13 @@ import {
type BundledTheme,
} from "shiki";
import { parseDiffPatch, getLanguageFromFilename } from "./github-utils";
import { getBuiltInTheme } from "./code-themes/built-in";
import { DEFAULT_CODE_THEME_LIGHT, DEFAULT_CODE_THEME_DARK } from "./code-themes/types";
import { getTheme, getThemeVariant } from "./themes";
import type { ShikiTheme, ThemeVariant } from "./themes/types";
const FALLBACK_THEMES = [DEFAULT_CODE_THEME_LIGHT, DEFAULT_CODE_THEME_DARK] as const;
const FALLBACK_PAIR = { light: DEFAULT_CODE_THEME_LIGHT, dark: DEFAULT_CODE_THEME_DARK };
const DEFAULT_LIGHT_THEME = "vitesse-light";
const DEFAULT_DARK_THEME = "vitesse-black";
const FALLBACK_THEMES = [DEFAULT_LIGHT_THEME, DEFAULT_DARK_THEME] as const;
const FALLBACK_PAIR = { light: DEFAULT_LIGHT_THEME, dark: DEFAULT_DARK_THEME };
const MAX_TOKENIZE_LENGTH = 200_000; // Skip tokenization for very large inputs to avoid WASM OOM
function escapeHtml(str: string): string {
@@ -34,69 +36,107 @@ function getHighlighter(): Promise<Highlighter> {
}
/**
* Read code-theme-prefs cookie from the raw Cookie header.
* Read theme preferences from cookies.
* Uses dynamic import of next/headers to avoid issues in non-request contexts.
* Returns defaults if anything goes wrong.
*/
async function readThemePrefsFromCookie(): Promise<{ light: string; dark: string }> {
async function readThemePrefsFromCookie(): Promise<{
themeId: string | null;
mode: "dark" | "light";
}> {
try {
// Dynamic import + 200ms timeout to guard against hanging cookies()/headers()
const result = await Promise.race([
(async () => {
const { cookies } = await import("next/headers");
const cookieStore = await cookies();
const raw = cookieStore.get("code-theme-prefs")?.value;
if (!raw) return FALLBACK_PAIR;
const parsed = JSON.parse(raw);
return {
light: (parsed.light as string) || FALLBACK_PAIR.light,
dark: (parsed.dark as string) || FALLBACK_PAIR.dark,
};
const themeId = cookieStore.get("color-theme")?.value ?? null;
const mode =
(cookieStore.get("color-mode")?.value as
| "dark"
| "light") ?? "dark";
return { themeId, mode };
})(),
new Promise<{ light: string; dark: string }>((resolve) =>
setTimeout(() => resolve(FALLBACK_PAIR), 200),
new Promise<{ themeId: string | null; mode: "dark" | "light" }>((resolve) =>
setTimeout(() => resolve({ themeId: null, mode: "dark" }), 200),
),
]);
return result;
} catch {
return FALLBACK_PAIR;
return { themeId: null, mode: "dark" };
}
}
/**
* Ensure a theme is loaded in the highlighter.
* Handles built-in Shiki themes by name and custom themes from DB JSON.
* Ensure a built-in Shiki theme is loaded.
*/
async function ensureThemeLoaded(highlighter: Highlighter, themeId: string): Promise<string> {
async function ensureBuiltInThemeLoaded(
highlighter: Highlighter,
themeId: string,
): Promise<string> {
const loaded = highlighter.getLoadedThemes();
if (loaded.includes(themeId)) return themeId;
// Check if it's a built-in Shiki theme
const builtIn = getBuiltInTheme(themeId);
if (builtIn) {
try {
await highlighter.loadTheme(themeId as BundledTheme);
return themeId;
} catch {
// Fall through to fallback
}
}
// Check if it's a custom theme from DB (lazy import to avoid bundling issues)
try {
const { getCustomTheme } = await import("./code-themes/store");
const custom = await getCustomTheme(themeId);
if (custom) {
const themeJson = JSON.parse(custom.themeJson);
themeJson.name = themeId;
await highlighter.loadTheme(themeJson);
return themeId;
}
await highlighter.loadTheme(themeId as BundledTheme);
return themeId;
} catch {
// Fall through to fallback
return "";
}
}
return "";
/**
* Load a built-in theme with a custom background color.
* Returns a unique theme name that can be used for highlighting.
*/
async function loadThemeWithCustomBg(
highlighter: Highlighter,
baseThemeId: string,
bgColor: string,
uniqueName: string,
): Promise<string> {
const loaded = highlighter.getLoadedThemes();
if (loaded.includes(uniqueName)) return uniqueName;
try {
// Ensure base theme is loaded first
await ensureBuiltInThemeLoaded(highlighter, baseThemeId);
// Get the theme object and modify its background
const baseTheme = highlighter.getTheme(baseThemeId);
const modifiedTheme = {
...baseTheme,
name: uniqueName,
colors: {
...baseTheme.colors,
"editor.background": bgColor,
},
};
await highlighter.loadTheme(modifiedTheme);
return uniqueName;
} catch {
// Fall back to the original theme
return baseThemeId;
}
}
/**
* Load a custom ShikiTheme object into the highlighter.
*/
async function loadCustomSyntaxTheme(
highlighter: Highlighter,
theme: ShikiTheme,
uniqueName: string,
): Promise<string> {
const loaded = highlighter.getLoadedThemes();
if (loaded.includes(uniqueName)) return uniqueName;
try {
const themeWithName = { ...theme, name: uniqueName };
await highlighter.loadTheme(themeWithName);
return uniqueName;
} catch {
return "";
}
}
// Cache theme pair per-request to avoid redundant cookie reads + theme loads
@@ -120,17 +160,67 @@ async function resolveThemePair(
highlighter: Highlighter,
): Promise<{ light: string; dark: string }> {
try {
const prefs = await readThemePrefsFromCookie();
const { themeId } = await readThemePrefsFromCookie();
const appTheme = themeId ? getTheme(themeId) : null;
const light =
(await ensureThemeLoaded(highlighter, prefs.light)) ||
(await ensureThemeLoaded(highlighter, DEFAULT_CODE_THEME_LIGHT)) ||
DEFAULT_CODE_THEME_LIGHT;
let light = DEFAULT_LIGHT_THEME;
let dark = DEFAULT_DARK_THEME;
const dark =
(await ensureThemeLoaded(highlighter, prefs.dark)) ||
(await ensureThemeLoaded(highlighter, DEFAULT_CODE_THEME_DARK)) ||
DEFAULT_CODE_THEME_DARK;
if (appTheme) {
// Load light variant's syntax theme
if (appTheme.light.syntax) {
const customLightName = `${appTheme.id}-syntax-light`;
const loadedName = await loadCustomSyntaxTheme(
highlighter,
appTheme.light.syntax,
customLightName,
);
if (loadedName) light = loadedName;
} else {
// No custom syntax - use vitesse but with the theme's code-bg
const codeBg = appTheme.light.colors["--code-bg"];
if (codeBg) {
const customName = `${appTheme.id}-light-vitesse`;
light = await loadThemeWithCustomBg(
highlighter,
DEFAULT_LIGHT_THEME,
codeBg,
customName,
);
}
}
// Load dark variant's syntax theme
if (appTheme.dark.syntax) {
const customDarkName = `${appTheme.id}-syntax-dark`;
const loadedName = await loadCustomSyntaxTheme(
highlighter,
appTheme.dark.syntax,
customDarkName,
);
if (loadedName) dark = loadedName;
} else {
// No custom syntax - use vitesse but with the theme's code-bg
const codeBg = appTheme.dark.colors["--code-bg"];
if (codeBg) {
const customName = `${appTheme.id}-dark-vitesse`;
dark = await loadThemeWithCustomBg(
highlighter,
DEFAULT_DARK_THEME,
codeBg,
customName,
);
}
}
}
// Ensure fallback themes are loaded
if (light === DEFAULT_LIGHT_THEME) {
await ensureBuiltInThemeLoaded(highlighter, DEFAULT_LIGHT_THEME);
}
if (dark === DEFAULT_DARK_THEME) {
await ensureBuiltInThemeLoaded(highlighter, DEFAULT_DARK_THEME);
}
return { light, dark };
} catch {
+20 -7
View File
@@ -1,16 +1,29 @@
import type { ThemeDefinition } from "./themes/types";
import type { ThemeDefinition, ThemeVariant } from "./themes/types";
interface ThemeScriptData {
dark: { colors: Record<string, string> };
light: { colors: Record<string, string> };
}
/**
* Generate an inline script that applies the saved color theme before first paint.
* If no theme is saved, detects system preference (dark/light) and picks the
* user's preferred theme for that mode. Sets both CSS variables and the dark/light class.
* Reads theme ID and mode from localStorage, then applies the correct variant's colors.
*/
export function generateThemeScript(themes: ThemeDefinition[]): string {
// Serialize: id → { mode, colors }
const data: Record<string, { mode: string; colors: Record<string, string> }> = {};
const data: Record<string, ThemeScriptData> = {};
for (const t of themes) {
data[t.id] = { mode: t.mode, colors: { ...t.colors } };
data[t.id] = {
dark: { colors: { ...t.dark.colors } },
light: { colors: { ...t.light.colors } },
};
}
return `(function(){try{var d=document.documentElement;var id=localStorage.getItem("color-theme");if(!id){var prefersDark=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches;var dk=localStorage.getItem("dark-theme")||"midnight";var lt=localStorage.getItem("light-theme")||"hub-light";id=prefersDark?dk:lt;localStorage.setItem("color-theme",id)}var themes=${JSON.stringify(data)};var t=themes[id];if(!t)t=themes["midnight"];if(!t)return;if(t.mode==="dark"){d.classList.add("dark");d.classList.remove("light");d.style.colorScheme="dark"}else{d.classList.remove("dark");d.classList.add("light");d.style.colorScheme="light"}localStorage.setItem("theme",t.mode);if(id!=="midnight"){for(var k in t.colors){d.style.setProperty(k,t.colors[k])}}var cp=localStorage.getItem("code-theme-prefs");if(cp){try{var p=JSON.parse(cp);if(p.bg)d.style.setProperty("--code-theme-bg",p.bg);if(p.font)d.style.setProperty("--code-font-override",p.font);if(p.fontSize)d.style.setProperty("--code-font-size",p.fontSize+"px")}catch(e2){}}}catch(e){}})()`;
const legacyMap: Record<string, { themeId: string; mode: string }> = {
midnight: { themeId: "hub", mode: "dark" },
"hub-light": { themeId: "hub", mode: "light" },
"hub-dark": { themeId: "zinc", mode: "dark" },
dawn: { themeId: "ember", mode: "light" },
};
return `(function(){try{var d=document.documentElement;var themes=${JSON.stringify(data)};var legacy=${JSON.stringify(legacyMap)};var id=localStorage.getItem("color-theme");var mode=localStorage.getItem("color-mode");if(id&&legacy[id]){var m=legacy[id];id=m.themeId;mode=m.mode;localStorage.setItem("color-theme",id);localStorage.setItem("color-mode",mode)}if(!id)id="hub";if(!mode){var prefersDark=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches;mode=prefersDark?"dark":"light";localStorage.setItem("color-mode",mode)}var t=themes[id];if(!t)t=themes["hub"];if(!t)return;var v=t[mode];if(!v)v=t.dark;if(mode==="dark"){d.classList.add("dark");d.classList.remove("light");d.style.colorScheme="dark"}else{d.classList.remove("dark");d.classList.add("light");d.style.colorScheme="light"}localStorage.setItem("theme",mode);if(!(id==="hub"&&mode==="dark")){for(var k in v.colors){d.style.setProperty(k,v.colors[k])}}}catch(e){}})()`;
}
+61 -31
View File
@@ -1,16 +1,49 @@
import type { ThemeColors, ThemeDefinition } from "./types";
import { midnight, hubLight, hubDark, ember, arctic, nordWave, dawn } from "./themes";
import type { ThemeColors, ThemeDefinition, ThemeVariant, ShikiTheme } from "./types";
import {
arctic,
cloudflare,
ember,
forest,
gemini,
hub,
mintlify,
noir,
nordWave,
openai,
rabbit,
stripe,
supabase,
tailwind,
vercel,
zinc,
LEGACY_THEME_MAP,
} from "./themes";
export type { ThemeColors, ThemeDefinition };
const themes: ThemeDefinition[] = [
hub,
ember,
zinc,
arctic,
nordWave,
vercel,
rabbit,
supabase,
tailwind,
openai,
mintlify,
cloudflare,
gemini,
stripe,
noir,
forest,
];
export type { ThemeColors, ThemeDefinition, ThemeVariant, ShikiTheme };
export const STORAGE_KEY = "color-theme";
export const DARK_THEME_KEY = "dark-theme";
export const LIGHT_THEME_KEY = "light-theme";
export const DEFAULT_THEME_ID = "midnight";
export const DARK_THEME_ID = "midnight";
export const LIGHT_THEME_ID = "hub-light";
const themes: ThemeDefinition[] = [midnight, hubDark, hubLight, ember, arctic, nordWave, dawn];
export const MODE_KEY = "color-mode";
export const DEFAULT_THEME_ID = "hub";
export const DEFAULT_MODE: "dark" | "light" = "dark";
const themeMap = new Map(themes.map((t) => [t.id, t]));
@@ -18,35 +51,33 @@ export function listThemes(): ThemeDefinition[] {
return themes;
}
export function listDarkThemes(): ThemeDefinition[] {
return themes.filter((t) => t.mode === "dark");
}
export function listLightThemes(): ThemeDefinition[] {
return themes.filter((t) => t.mode === "light");
}
export function getTheme(id: string): ThemeDefinition | undefined {
return themeMap.get(id);
}
/**
* Apply a theme by setting CSS custom properties on documentElement.
* Also syncs the dark/light class to match the theme's mode.
*/
export function applyTheme(themeId: string): void {
export function getThemeVariant(id: string, mode: "dark" | "light"): ThemeVariant | undefined {
const theme = themeMap.get(id);
return theme?.[mode];
}
export function migrateLegacyThemeId(
legacyId: string,
): { themeId: string; mode: "dark" | "light" } | undefined {
return LEGACY_THEME_MAP[legacyId];
}
export function applyTheme(themeId: string, mode: "dark" | "light"): void {
const el = document.documentElement;
const theme = getTheme(themeId);
const variant = theme?.[mode];
// Get all CSS var keys from the midnight theme as reference
const allKeys = Object.keys(midnight.colors) as (keyof ThemeColors)[];
const hubDark = hub.dark;
const allKeys = Object.keys(hubDark.colors) as (keyof ThemeColors)[];
if (!theme || themeId === DEFAULT_THEME_ID) {
// Remove all inline overrides — let globals.css take over
if (!variant || (themeId === DEFAULT_THEME_ID && mode === DEFAULT_MODE)) {
for (const key of allKeys) {
el.style.removeProperty(key);
}
// Still sync the dark/light class for midnight
el.classList.add("dark");
el.classList.remove("light");
el.style.colorScheme = "dark";
@@ -54,11 +85,10 @@ export function applyTheme(themeId: string): void {
}
for (const key of allKeys) {
el.style.setProperty(key, theme.colors[key]);
el.style.setProperty(key, variant.colors[key]);
}
// Sync the dark/light class immediately alongside CSS vars
if (theme.mode === "dark") {
if (mode === "dark") {
el.classList.add("dark");
el.classList.remove("light");
el.style.colorScheme = "dark";
File diff suppressed because it is too large Load Diff
+41 -4
View File
@@ -57,14 +57,51 @@ export interface ThemeColors {
"--contrib-4": string;
}
export interface ThemeDefinition {
id: string;
export interface ShikiThemeInput {
foreground: string;
constant?: string;
string?: string;
comment?: string;
keyword?: string;
parameter?: string;
function?: string;
stringExpression?: string;
punctuation?: string;
link?: string;
number?: string;
property?: string;
objectLiteral?: string;
highlight?: string;
highlightHover?: string;
highlightBorder?: string;
diffDeleted?: string;
diffInserted?: string;
}
export interface ShikiTheme {
name: string;
description: string;
mode: "dark" | "light";
type: "light" | "dark";
colors: Record<string, string>;
tokenColors: Array<{
scope: string | string[];
settings: { foreground?: string; fontStyle?: string };
}>;
}
export interface ThemeVariant {
/** Accent color for preview dot */
accentPreview: string;
/** Background color for preview dot */
bgPreview: string;
colors: ThemeColors;
/** Optional syntax highlighting theme for code blocks (matches variant mode) */
syntax?: ShikiTheme;
}
export interface ThemeDefinition {
id: string;
name: string;
description: string;
dark: ThemeVariant;
light: ThemeVariant;
}
+5
View File
@@ -5,6 +5,7 @@ export interface UserSettings {
displayName: string | null;
theme: string;
colorTheme: string;
colorMode: string;
ghostModel: string;
useOwnApiKey: boolean;
openrouterApiKey: string | null;
@@ -22,6 +23,7 @@ function toSettings(row: {
displayName: string | null;
theme: string;
colorTheme: string;
colorMode?: string;
ghostModel: string;
useOwnApiKey: boolean;
openrouterApiKey: string | null;
@@ -38,6 +40,7 @@ function toSettings(row: {
displayName: row.displayName,
theme: row.theme,
colorTheme: row.colorTheme,
colorMode: row.colorMode ?? "dark",
ghostModel: row.ghostModel,
useOwnApiKey: row.useOwnApiKey,
openrouterApiKey: row.openrouterApiKey,
@@ -76,6 +79,7 @@ export async function updateUserSettings(
| "displayName"
| "theme"
| "colorTheme"
| "colorMode"
| "ghostModel"
| "useOwnApiKey"
| "openrouterApiKey"
@@ -101,6 +105,7 @@ export async function updateUserSettings(
if (updates.displayName !== undefined) data.displayName = updates.displayName;
if (updates.theme !== undefined) data.theme = updates.theme;
if (updates.colorTheme !== undefined) data.colorTheme = updates.colorTheme;
if (updates.colorMode !== undefined) data.colorMode = updates.colorMode;
if (updates.ghostModel !== undefined) data.ghostModel = updates.ghostModel;
if (updates.useOwnApiKey !== undefined) data.useOwnApiKey = updates.useOwnApiKey;
if (updates.openrouterApiKey !== undefined)