create-next-pro-cli 0.1.33 → 0.1.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/templates/Projects/default/src/app/[locale]/layout.tsx +1 -9
- package/templates/Projects/default/src/ui/_global/ThemeToggle.tsx +61 -15
- package/templates/Projects/default/tests/e2e/template-remediation.playwright.ts +90 -10
- package/templates/Projects/default/tests/unit/template-baseline.test.ts +19 -0
package/package.json
CHANGED
|
@@ -4,7 +4,6 @@ import { setRequestLocale } from "next-intl/server";
|
|
|
4
4
|
import { hasLocale } from "next-intl";
|
|
5
5
|
import type { Metadata } from "next";
|
|
6
6
|
import { notFound } from "next/navigation";
|
|
7
|
-
import Script from "next/script";
|
|
8
7
|
import React from "react";
|
|
9
8
|
import "@/app/styles/globals.css";
|
|
10
9
|
import { routing } from "@/lib/i18n/routing";
|
|
@@ -37,18 +36,11 @@ export default async function LocaleLayout({
|
|
|
37
36
|
setRequestLocale(locale);
|
|
38
37
|
const messages = getMessages(locale);
|
|
39
38
|
return (
|
|
40
|
-
<html lang={locale} className="light"
|
|
39
|
+
<html lang={locale} className="light">
|
|
41
40
|
<body>
|
|
42
41
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
|
43
42
|
{children}
|
|
44
43
|
</NextIntlClientProvider>
|
|
45
|
-
<Script
|
|
46
|
-
id="theme-initializer"
|
|
47
|
-
strategy="beforeInteractive"
|
|
48
|
-
dangerouslySetInnerHTML={{
|
|
49
|
-
__html: `try{var t=localStorage.getItem("theme");var d=t?t==="dark":window.matchMedia("(prefers-color-scheme: dark)").matches;document.documentElement.classList.toggle("dark",d);document.documentElement.classList.toggle("light",!d);}catch{document.documentElement.classList.add("light");}`,
|
|
50
|
-
}}
|
|
51
|
-
/>
|
|
52
44
|
</body>
|
|
53
45
|
</html>
|
|
54
46
|
);
|
|
@@ -4,30 +4,81 @@ import { Moon, Sun } from "lucide-react";
|
|
|
4
4
|
import { Button } from "./Button";
|
|
5
5
|
import { useLocale, useTranslations } from "next-intl";
|
|
6
6
|
|
|
7
|
+
type Theme = "dark" | "light";
|
|
8
|
+
|
|
7
9
|
const themeChangeEvent = "template-theme-change";
|
|
8
10
|
|
|
9
|
-
function getThemeSnapshot() {
|
|
11
|
+
function getThemeSnapshot(): Theme {
|
|
10
12
|
return document.documentElement.classList.contains("dark") ? "dark" : "light";
|
|
11
13
|
}
|
|
12
14
|
|
|
13
|
-
function getServerThemeSnapshot() {
|
|
15
|
+
function getServerThemeSnapshot(): Theme {
|
|
14
16
|
return "light";
|
|
15
17
|
}
|
|
16
18
|
|
|
19
|
+
function isTheme(value: string | null): value is Theme {
|
|
20
|
+
return value === "dark" || value === "light";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readStoredTheme(): Theme | null {
|
|
24
|
+
try {
|
|
25
|
+
const theme = localStorage.getItem("theme");
|
|
26
|
+
return isTheme(theme) ? theme : null;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getSystemTheme(): Theme {
|
|
33
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
34
|
+
? "dark"
|
|
35
|
+
: "light";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function setDocumentTheme(theme: Theme) {
|
|
39
|
+
const isDark = theme === "dark";
|
|
40
|
+
document.documentElement.classList.toggle("dark", isDark);
|
|
41
|
+
document.documentElement.classList.toggle("light", !isDark);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function persistTheme(theme: Theme) {
|
|
45
|
+
try {
|
|
46
|
+
localStorage.setItem("theme", theme);
|
|
47
|
+
} catch {
|
|
48
|
+
// The active document theme still works when storage is unavailable.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
17
52
|
function subscribeToThemeChange(onStoreChange: () => void) {
|
|
53
|
+
const handleStorage = (event: StorageEvent) => {
|
|
54
|
+
if (event.key !== null && event.key !== "theme") return;
|
|
55
|
+
|
|
56
|
+
setDocumentTheme(readStoredTheme() ?? getSystemTheme());
|
|
57
|
+
onStoreChange();
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const colorScheme = window.matchMedia("(prefers-color-scheme: dark)");
|
|
61
|
+
const handleSystemThemeChange = (event: MediaQueryListEvent) => {
|
|
62
|
+
if (readStoredTheme() !== null) return;
|
|
63
|
+
|
|
64
|
+
setDocumentTheme(event.matches ? "dark" : "light");
|
|
65
|
+
onStoreChange();
|
|
66
|
+
};
|
|
67
|
+
|
|
18
68
|
window.addEventListener(themeChangeEvent, onStoreChange);
|
|
19
|
-
window.addEventListener("storage",
|
|
69
|
+
window.addEventListener("storage", handleStorage);
|
|
70
|
+
colorScheme.addEventListener("change", handleSystemThemeChange);
|
|
20
71
|
|
|
21
72
|
return () => {
|
|
22
73
|
window.removeEventListener(themeChangeEvent, onStoreChange);
|
|
23
|
-
window.removeEventListener("storage",
|
|
74
|
+
window.removeEventListener("storage", handleStorage);
|
|
75
|
+
colorScheme.removeEventListener("change", handleSystemThemeChange);
|
|
24
76
|
};
|
|
25
77
|
}
|
|
26
78
|
|
|
27
|
-
function applyTheme(
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
localStorage.setItem("theme", dark ? "dark" : "light");
|
|
79
|
+
function applyTheme(theme: Theme, persist: boolean) {
|
|
80
|
+
setDocumentTheme(theme);
|
|
81
|
+
if (persist) persistTheme(theme);
|
|
31
82
|
window.dispatchEvent(new Event(themeChangeEvent));
|
|
32
83
|
}
|
|
33
84
|
|
|
@@ -42,17 +93,12 @@ export default function ThemeToggle() {
|
|
|
42
93
|
const isDark = theme === "dark";
|
|
43
94
|
|
|
44
95
|
useEffect(() => {
|
|
45
|
-
|
|
46
|
-
const shouldUseDark = storedTheme
|
|
47
|
-
? storedTheme === "dark"
|
|
48
|
-
: window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
49
|
-
|
|
50
|
-
applyTheme(shouldUseDark);
|
|
96
|
+
applyTheme(readStoredTheme() ?? getSystemTheme(), false);
|
|
51
97
|
}, [locale]);
|
|
52
98
|
|
|
53
99
|
return (
|
|
54
100
|
<Button
|
|
55
|
-
onClick={() => applyTheme(
|
|
101
|
+
onClick={() => applyTheme(isDark ? "light" : "dark", true)}
|
|
56
102
|
className="rounded-full p-0 dark:hover:bg-white/10 light:hover:bg-black/10 transition-colors"
|
|
57
103
|
aria-label={t("toggle_theme")}
|
|
58
104
|
variant="ghost"
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
|
+
import path from "node:path";
|
|
2
3
|
|
|
3
4
|
const captureByProject: Record<string, string> = {
|
|
4
5
|
desktop: "artifacts/captures/phase-2-11-template-reconciliation-desktop.png",
|
|
5
6
|
mobile: "artifacts/captures/phase-2-11-template-reconciliation-mobile.png",
|
|
6
7
|
};
|
|
7
8
|
|
|
9
|
+
const themeCaptureDirectory =
|
|
10
|
+
process.env.CNP_CAPTURE_DIRECTORY ?? "artifacts/captures";
|
|
11
|
+
|
|
8
12
|
test("public template routes render in both locales", async ({
|
|
9
13
|
page,
|
|
10
14
|
}, testInfo) => {
|
|
@@ -107,32 +111,108 @@ test("security headers and disabled auth are explicit", async ({ request }) => {
|
|
|
107
111
|
});
|
|
108
112
|
});
|
|
109
113
|
|
|
110
|
-
test("
|
|
114
|
+
test("ThemeToggle restores and persists the active theme without scripts", async ({
|
|
111
115
|
page,
|
|
112
|
-
}) => {
|
|
113
|
-
const
|
|
116
|
+
}, testInfo) => {
|
|
117
|
+
const themeConsoleIssues: string[] = [];
|
|
114
118
|
page.on("console", (message) => {
|
|
115
119
|
if (
|
|
116
|
-
message.type() === "warning" &&
|
|
117
|
-
(message.text().includes("
|
|
120
|
+
(message.type() === "warning" || message.type() === "error") &&
|
|
121
|
+
(message.text().toLowerCase().includes("hydrat") ||
|
|
118
122
|
message.text().includes("Encountered a script tag"))
|
|
119
123
|
) {
|
|
120
|
-
|
|
124
|
+
themeConsoleIssues.push(message.text());
|
|
121
125
|
}
|
|
122
126
|
});
|
|
123
127
|
|
|
124
|
-
await page.addInitScript(() =>
|
|
128
|
+
await page.addInitScript(() => {
|
|
129
|
+
if (localStorage.getItem("theme") === null) {
|
|
130
|
+
localStorage.setItem("theme", "dark");
|
|
131
|
+
}
|
|
132
|
+
});
|
|
125
133
|
await page.goto("/en", { waitUntil: "domcontentloaded" });
|
|
126
|
-
await expect(page.locator("#theme-initializer")).toHaveCount(
|
|
134
|
+
await expect(page.locator("#theme-initializer")).toHaveCount(0);
|
|
135
|
+
await expect(page.locator("html")).toHaveClass(/dark/);
|
|
136
|
+
await expect
|
|
137
|
+
.poll(() => page.evaluate(() => localStorage.getItem("theme")))
|
|
138
|
+
.toBe("dark");
|
|
139
|
+
|
|
140
|
+
if (testInfo.project.name === "desktop") {
|
|
141
|
+
await page.screenshot({
|
|
142
|
+
path: path.join(
|
|
143
|
+
themeCaptureDirectory,
|
|
144
|
+
"phase-2-16-theme-dark-desktop.png",
|
|
145
|
+
),
|
|
146
|
+
fullPage: true,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
await page.getByLabel("Select language").selectOption("fr");
|
|
151
|
+
await expect(page).toHaveURL(/\/fr$/);
|
|
127
152
|
await expect(page.locator("html")).toHaveClass(/dark/);
|
|
128
|
-
|
|
153
|
+
|
|
154
|
+
await page.getByLabel("Changer de thème").click();
|
|
155
|
+
await expect(page.locator("html")).toHaveClass(/light/);
|
|
156
|
+
await expect
|
|
157
|
+
.poll(() => page.evaluate(() => localStorage.getItem("theme")))
|
|
158
|
+
.toBe("light");
|
|
159
|
+
|
|
160
|
+
await page.reload({ waitUntil: "domcontentloaded" });
|
|
161
|
+
await expect(page.locator("html")).toHaveClass(/light/);
|
|
162
|
+
|
|
163
|
+
await page.goto("/fr/dashboard", { waitUntil: "domcontentloaded" });
|
|
164
|
+
await expect(page).toHaveURL(/\/fr\/login/);
|
|
165
|
+
await expect(page.locator("html")).toHaveClass(/light/);
|
|
166
|
+
|
|
167
|
+
if (testInfo.project.name === "desktop") {
|
|
168
|
+
await page.screenshot({
|
|
169
|
+
path: path.join(
|
|
170
|
+
themeCaptureDirectory,
|
|
171
|
+
"phase-2-16-theme-light-desktop.png",
|
|
172
|
+
),
|
|
173
|
+
fullPage: true,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
expect(themeConsoleIssues).toEqual([]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("ThemeToggle follows the system preference without persisting it", async ({
|
|
181
|
+
page,
|
|
182
|
+
}) => {
|
|
183
|
+
await page.emulateMedia({ colorScheme: "dark" });
|
|
184
|
+
await page.addInitScript(() => localStorage.removeItem("theme"));
|
|
185
|
+
await page.goto("/en", { waitUntil: "domcontentloaded" });
|
|
186
|
+
|
|
187
|
+
await expect(page.locator("html")).toHaveClass(/dark/);
|
|
188
|
+
await expect
|
|
189
|
+
.poll(() => page.evaluate(() => localStorage.getItem("theme")))
|
|
190
|
+
.toBeNull();
|
|
191
|
+
|
|
192
|
+
await page.emulateMedia({ colorScheme: "light" });
|
|
193
|
+
await expect(page.locator("html")).toHaveClass(/light/);
|
|
194
|
+
await expect
|
|
195
|
+
.poll(() => page.evaluate(() => localStorage.getItem("theme")))
|
|
196
|
+
.toBeNull();
|
|
129
197
|
});
|
|
130
198
|
|
|
131
|
-
test("
|
|
199
|
+
test("ThemeToggle remains usable when browser storage is unavailable", async ({
|
|
200
|
+
page,
|
|
201
|
+
}) => {
|
|
132
202
|
await page.emulateMedia({ colorScheme: "dark" });
|
|
203
|
+
await page.addInitScript(() => {
|
|
204
|
+
Object.defineProperty(window, "localStorage", {
|
|
205
|
+
configurable: true,
|
|
206
|
+
get() {
|
|
207
|
+
throw new DOMException("Storage is unavailable", "SecurityError");
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
});
|
|
133
211
|
await page.goto("/en", { waitUntil: "domcontentloaded" });
|
|
134
212
|
|
|
135
213
|
await expect(page.locator("html")).toHaveClass(/dark/);
|
|
214
|
+
await page.getByLabel("Toggle theme").click();
|
|
215
|
+
await expect(page.locator("html")).toHaveClass(/light/);
|
|
136
216
|
});
|
|
137
217
|
|
|
138
218
|
test("mobile navigation supports keyboard dismissal", async ({
|
|
@@ -58,4 +58,23 @@ describe("updated template baseline", () => {
|
|
|
58
58
|
].filter(Boolean);
|
|
59
59
|
expect(configuredAuthValues).toHaveLength(3);
|
|
60
60
|
});
|
|
61
|
+
|
|
62
|
+
test("delegates theme initialization to ThemeToggle without inline scripts", async () => {
|
|
63
|
+
const [layout, themeToggle] = await Promise.all([
|
|
64
|
+
source("src/app/[locale]/layout.tsx"),
|
|
65
|
+
source("src/ui/_global/ThemeToggle.tsx"),
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
expect(layout).toContain('className="light"');
|
|
69
|
+
expect(layout).not.toContain("next/script");
|
|
70
|
+
expect(layout).not.toContain("theme-initializer");
|
|
71
|
+
expect(layout).not.toContain("dangerouslySetInnerHTML");
|
|
72
|
+
expect(layout).not.toContain("suppressHydrationWarning");
|
|
73
|
+
|
|
74
|
+
expect(themeToggle).toContain('localStorage.getItem("theme")');
|
|
75
|
+
expect(themeToggle).toContain('localStorage.setItem("theme", theme)');
|
|
76
|
+
expect(themeToggle).toContain("prefers-color-scheme: dark");
|
|
77
|
+
expect(themeToggle).toContain('classList.toggle("dark", isDark)');
|
|
78
|
+
expect(themeToggle).toContain('classList.toggle("light", !isDark)');
|
|
79
|
+
});
|
|
61
80
|
});
|