feat: sync embedded analytics theme with parent app

This commit is contained in:
2026-04-02 20:11:33 +08:00
parent 1b5977a420
commit cc66034e59
8 changed files with 219 additions and 44 deletions

5
AGENTS.md Normal file
View File

@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

36
README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -30,8 +30,15 @@ export default function RootLayout({
<head> <head>
<script dangerouslySetInnerHTML={{ __html: ` <script dangerouslySetInnerHTML={{ __html: `
(function(){ (function(){
var parentTheme=null;
try{
if(window.self!==window.top){
var parentDoc=window.parent.document;
parentTheme=(parentDoc.body&&parentDoc.body.getAttribute('theme-mode')==='dark')||parentDoc.documentElement.classList.contains('dark')?'dark':'light';
}
}catch(e){}
var t=localStorage.getItem('theme')||'system'; var t=localStorage.getItem('theme')||'system';
var r=t==='system'?window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light':t; var r=parentTheme||(t==='system'?window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light':t);
document.documentElement.classList.add(r); document.documentElement.classList.add(r);
document.documentElement.setAttribute('data-theme',r); document.documentElement.setAttribute('data-theme',r);
})(); })();

View File

@@ -10,7 +10,7 @@ import { useTheme, type Theme } from "@/lib/theme";
export function Sidebar() { export function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const { t, locale, setLocale } = useI18n(); const { t, locale, setLocale } = useI18n();
const { theme, setTheme } = useTheme(); const { theme, setTheme, isEmbedded } = useTheme();
const nav = [ const nav = [
{ href: "/", label: t("nav.overview"), icon: LayoutDashboard }, { href: "/", label: t("nav.overview"), icon: LayoutDashboard },
@@ -76,22 +76,23 @@ export function Sidebar() {
{/* Controls */} {/* Controls */}
<div className="space-y-3 p-4 border-t" style={{ borderColor: "var(--surface-border)" }}> <div className="space-y-3 p-4 border-t" style={{ borderColor: "var(--surface-border)" }}>
{/* Theme switcher */} {!isEmbedded && (
<div className="flex gap-1 rounded-lg p-0.5" style={{ background: "var(--row-hover)", border: "1px solid var(--surface-border)" }}> <div className="flex gap-1 rounded-lg p-0.5" style={{ background: "var(--row-hover)", border: "1px solid var(--surface-border)" }}>
{themes.map(({ value, icon: Icon }) => ( {themes.map(({ value, icon: Icon }) => (
<button key={value} onClick={() => setTheme(value)} <button key={value} onClick={() => setTheme(value)}
className="flex-1 flex items-center justify-center rounded-md py-1.5 transition-colors" className="flex-1 flex items-center justify-center rounded-md py-1.5 transition-colors"
style={{ style={{
background: theme === value ? "var(--btn-active-bg)" : "transparent", background: theme === value ? "var(--btn-active-bg)" : "transparent",
color: theme === value ? "var(--text-accent)" : "var(--text-muted)", color: theme === value ? "var(--text-accent)" : "var(--text-muted)",
border: theme === value ? "1px solid var(--surface-border)" : "1px solid transparent", border: theme === value ? "1px solid var(--surface-border)" : "1px solid transparent",
}} }}
title={themes.find(t => t.value === value)?.label} title={themes.find(t => t.value === value)?.label}
> >
<Icon className="h-3.5 w-3.5" /> <Icon className="h-3.5 w-3.5" />
</button> </button>
))} ))}
</div> </div>
)}
{/* Language switcher */} {/* Language switcher */}
<button <button

20
lib/theme-sync.test.ts Normal file
View File

@@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test";
import { resolveIncomingThemeMode, resolveStoredTheme } from "./theme-sync";
describe("theme sync helpers", () => {
test("resolves stored analytics theme values", () => {
expect(resolveStoredTheme("light", true)).toBe("light");
expect(resolveStoredTheme("dark", false)).toBe("dark");
expect(resolveStoredTheme("system", true)).toBe("dark");
expect(resolveStoredTheme("system", false)).toBe("light");
});
test("maps new-api theme messages to analytics themes", () => {
expect(resolveIncomingThemeMode("light", true)).toBe("light");
expect(resolveIncomingThemeMode("dark", false)).toBe("dark");
expect(resolveIncomingThemeMode("auto", true)).toBe("dark");
expect(resolveIncomingThemeMode("auto", false)).toBe("light");
expect(resolveIncomingThemeMode("system", true)).toBe("dark");
expect(resolveIncomingThemeMode("unknown", true)).toBeNull();
});
});

29
lib/theme-sync.ts Normal file
View File

@@ -0,0 +1,29 @@
import type { Theme } from "./theme";
export type ResolvedTheme = "light" | "dark";
export function resolveStoredTheme(
theme: Theme,
prefersDark: boolean,
): ResolvedTheme {
if (theme === "system") {
return prefersDark ? "dark" : "light";
}
return theme;
}
export function resolveIncomingThemeMode(
mode: unknown,
prefersDark: boolean,
): ResolvedTheme | null {
if (mode === "light" || mode === "dark") {
return mode;
}
if (mode === "auto" || mode === "system") {
return prefersDark ? "dark" : "light";
}
return null;
}

View File

@@ -1,61 +1,137 @@
"use client"; "use client";
import { createContext, useContext, useState, useEffect, type ReactNode } from "react"; import { createContext, useContext, useState, useEffect, startTransition, type ReactNode } from "react";
import { resolveIncomingThemeMode, resolveStoredTheme, type ResolvedTheme } from "./theme-sync";
export type Theme = "light" | "dark" | "system"; export type Theme = "light" | "dark" | "system";
interface ThemeContextType { interface ThemeContextType {
theme: Theme; theme: Theme;
setTheme: (t: Theme) => void; setTheme: (t: Theme) => void;
resolved: "light" | "dark"; resolved: ResolvedTheme;
isEmbedded: boolean;
}
function getPrefersDark() {
if (typeof window === "undefined") {
return true;
}
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
function getIsEmbedded() {
if (typeof window === "undefined") {
return false;
}
try {
return window.self !== window.top;
} catch {
return true;
}
}
function getParentResolvedTheme(): ResolvedTheme | null {
if (!getIsEmbedded()) {
return null;
}
try {
const parentRoot = window.parent.document.documentElement;
const parentBody = window.parent.document.body;
if (parentBody?.getAttribute("theme-mode") === "dark" || parentRoot.classList.contains("dark")) {
return "dark";
}
return "light";
} catch {
return null;
}
} }
const ThemeContext = createContext<ThemeContextType>({ const ThemeContext = createContext<ThemeContextType>({
theme: "system", theme: "system",
setTheme: () => {}, setTheme: () => {},
resolved: "dark", resolved: "dark",
isEmbedded: false,
}); });
export function ThemeProvider({ children }: { children: ReactNode }) { export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>("system"); const [theme, setThemeState] = useState<Theme>("system");
const [resolved, setResolved] = useState<"light" | "dark">("dark"); const [isEmbedded, setIsEmbedded] = useState(getIsEmbedded);
const [parentResolved, setParentResolved] = useState<ResolvedTheme | null>(getParentResolvedTheme);
const [resolved, setResolved] = useState<ResolvedTheme>(
() => getParentResolvedTheme() ?? resolveStoredTheme("system", getPrefersDark()),
);
useEffect(() => { useEffect(() => {
const saved = localStorage.getItem("theme") as Theme | null; const saved = localStorage.getItem("theme") as Theme | null;
if (saved && ["light", "dark", "system"].includes(saved)) { if (saved && ["light", "dark", "system"].includes(saved)) {
setThemeState(saved); startTransition(() => {
setThemeState(saved);
});
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
const resolve = () => { const nextIsEmbedded = getIsEmbedded();
if (theme === "system") { const nextParentResolved = getParentResolvedTheme();
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
startTransition(() => {
setIsEmbedded(nextIsEmbedded);
setParentResolved((current) => current ?? nextParentResolved);
});
}, []);
useEffect(() => {
if (!isEmbedded) {
return;
}
const handleMessage = (event: MessageEvent) => {
if (event.source !== window.parent) {
return;
}
const nextResolved = resolveIncomingThemeMode(
event.data?.themeMode,
window.matchMedia("(prefers-color-scheme: dark)").matches,
);
if (nextResolved) {
setParentResolved(nextResolved);
} }
return theme;
}; };
const r = resolve(); window.addEventListener("message", handleMessage);
setResolved(r); return () => window.removeEventListener("message", handleMessage);
}, [isEmbedded]);
useEffect(() => {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const root = document.documentElement; const root = document.documentElement;
root.classList.remove("light", "dark");
root.classList.add(r);
root.setAttribute("data-theme", r);
if (theme === "system") { const applyResolved = (nextResolved: ResolvedTheme) => {
const mq = window.matchMedia("(prefers-color-scheme: dark)"); setResolved(nextResolved);
const handler = () => { root.classList.remove("light", "dark");
const nr = mq.matches ? "dark" : "light"; root.classList.add(nextResolved);
setResolved(nr); root.setAttribute("data-theme", nextResolved);
root.classList.remove("light", "dark"); };
root.classList.add(nr);
root.setAttribute("data-theme", nr); applyResolved(parentResolved ?? resolveStoredTheme(theme, mq.matches));
};
mq.addEventListener("change", handler); if (parentResolved !== null || theme !== "system") {
return () => mq.removeEventListener("change", handler); return;
} }
}, [theme]);
const handleSystemThemeChange = (event: MediaQueryListEvent) => {
applyResolved(resolveStoredTheme(theme, event.matches));
};
mq.addEventListener("change", handleSystemThemeChange);
return () => mq.removeEventListener("change", handleSystemThemeChange);
}, [parentResolved, theme]);
const setTheme = (t: Theme) => { const setTheme = (t: Theme) => {
setThemeState(t); setThemeState(t);
@@ -63,7 +139,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
}; };
return ( return (
<ThemeContext.Provider value={{ theme, setTheme, resolved }}> <ThemeContext.Provider value={{ theme, setTheme, resolved, isEmbedded }}>
{children} {children}
</ThemeContext.Provider> </ThemeContext.Provider>
); );