Merge pull request 'Add debugging tools and dark-theme surface detection for theme QA' (#2) from UI-Overhaul-Test2 into main
Reviewed-on: #2
|
|
@ -0,0 +1,59 @@
|
||||||
|
/* Verify computed styles for a given theme class after runtime swap. */
|
||||||
|
const { spawn } = require("node:child_process");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const http = require("node:http");
|
||||||
|
const EDGE = "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe";
|
||||||
|
const PORT = 9337;
|
||||||
|
function httpJson(path) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request({ host: "127.0.0.1", port: PORT, path }, (res) => {
|
||||||
|
let d = "";
|
||||||
|
res.on("data", (c) => (d += c));
|
||||||
|
res.on("end", () => { try { resolve(JSON.parse(d)); } catch { resolve(d); } });
|
||||||
|
});
|
||||||
|
req.on("error", reject); req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
(async () => {
|
||||||
|
const profile = fs.mkdtempSync("/tmp/edge-prof-");
|
||||||
|
const proc = spawn(EDGE, ["--headless=new", `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "--disable-gpu", "about:blank"], { stdio: "ignore" });
|
||||||
|
let targets = null;
|
||||||
|
for (let i = 0; i < 40; i++) { await new Promise((r) => setTimeout(r, 500)); try { targets = await httpJson("/json/list"); break; } catch {} }
|
||||||
|
if (!targets) { console.error("no targets"); process.exit(1); }
|
||||||
|
const page = targets.find((t) => t.type === "page");
|
||||||
|
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||||
|
await new Promise((r) => (ws.onopen = r));
|
||||||
|
let id = 0;
|
||||||
|
const send = (method, params = {}) => new Promise((resolve) => {
|
||||||
|
const myId = ++id;
|
||||||
|
ws.send(JSON.stringify({ id: myId, method, params }));
|
||||||
|
const t = setInterval(() => { const m = ws._buf.find((b) => b.id === myId); if (m) { clearInterval(t); resolve(m.result ?? m.error); } }, 10);
|
||||||
|
});
|
||||||
|
ws._buf = [];
|
||||||
|
ws.onmessage = (m) => { const msg = JSON.parse(m.data); if (msg.id) { ws._buf.push(msg); return; }
|
||||||
|
if (msg.method === "Runtime.consoleAPICalled") console.log("[c]", msg.params.args.map((a) => a.value ?? a.description ?? a.type).join(" ").slice(0, 160)); };
|
||||||
|
await send("Runtime.enable");
|
||||||
|
await send("Network.enable");
|
||||||
|
const cookieFile = process.argv[2];
|
||||||
|
if (cookieFile) {
|
||||||
|
const line = fs.readFileSync(cookieFile, "utf8").split("\n").map((l) => l.trim()).find((l) => l.includes("authjs.session-token"));
|
||||||
|
if (line) await send("Network.setCookies", { cookies: [{ name: "authjs.session-token", value: line.split("\t").pop(), domain: "localhost", path: "/", httpOnly: true, sameSite: "Lax" }] });
|
||||||
|
}
|
||||||
|
await send("Page.enable");
|
||||||
|
await send("Page.navigate", { url: "http://localhost:3210/" });
|
||||||
|
await new Promise((r) => setTimeout(r, 4000));
|
||||||
|
const theme = process.argv[3] || "dark";
|
||||||
|
await send("Runtime.evaluate", { expression: `document.documentElement.className = ${JSON.stringify(theme)}` });
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
const info = await send("Runtime.evaluate", { expression: `(() => {
|
||||||
|
const card = document.querySelector('[aria-label^="Drag to move"]')?.closest('[class*="rounded-2xl"]');
|
||||||
|
const checkbox = card ? card.querySelector('button[class*="size-4"]') : null;
|
||||||
|
return JSON.stringify({
|
||||||
|
htmlClass: document.documentElement.className.split(" ").slice(-1)[0],
|
||||||
|
checkboxBorder: checkbox ? getComputedStyle(checkbox).borderColor : null,
|
||||||
|
cardBg: card ? getComputedStyle(card).backgroundColor : null,
|
||||||
|
});
|
||||||
|
})()`, returnByValue: true });
|
||||||
|
console.log(info?.result?.value);
|
||||||
|
proc.kill(); process.exit(0);
|
||||||
|
})().catch((e) => { console.error(e.message); process.exit(1); });
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
/* Print console messages + page errors for one URL in headless Edge. */
|
||||||
|
const { spawn } = require("node:child_process");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const http = require("node:http");
|
||||||
|
|
||||||
|
const EDGE = "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe";
|
||||||
|
const PORT = 9334;
|
||||||
|
const url = process.argv[2] || "http://localhost:3210/";
|
||||||
|
const cookieFile = process.argv[3];
|
||||||
|
|
||||||
|
function httpJson(path) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request({ host: "127.0.0.1", port: PORT, path }, (res) => {
|
||||||
|
let d = "";
|
||||||
|
res.on("data", (c) => (d += c));
|
||||||
|
res.on("end", () => { try { resolve(JSON.parse(d)); } catch { resolve(d); } });
|
||||||
|
});
|
||||||
|
req.on("error", reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const profile = fs.mkdtempSync("/tmp/edge-prof-");
|
||||||
|
const proc = spawn(EDGE, [
|
||||||
|
"--headless=new", `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`,
|
||||||
|
"--no-first-run", "--no-default-browser-check", "--disable-gpu", "about:blank",
|
||||||
|
], { stdio: "ignore" });
|
||||||
|
|
||||||
|
let targets = null;
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
try { targets = await httpJson("/json/list"); break; } catch {}
|
||||||
|
}
|
||||||
|
if (!targets) { console.error("edge did not start"); process.exit(1); }
|
||||||
|
const page = targets.find((t) => t.type === "page");
|
||||||
|
|
||||||
|
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||||
|
await new Promise((r) => (ws.onopen = r));
|
||||||
|
let id = 0;
|
||||||
|
const send = (method, params = {}) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const myId = ++id;
|
||||||
|
ws.send(JSON.stringify({ id: myId, method, params }));
|
||||||
|
const t = setInterval(() => {
|
||||||
|
const m = ws._buf.find((b) => b.id === myId);
|
||||||
|
if (m) { clearInterval(t); resolve(m.result ?? m.error); }
|
||||||
|
}, 10);
|
||||||
|
});
|
||||||
|
ws._buf = [];
|
||||||
|
ws.onmessage = (m) => {
|
||||||
|
const msg = JSON.parse(m.data);
|
||||||
|
if (msg.id) { ws._buf.push(msg); return; }
|
||||||
|
if (msg.method === "Runtime.consoleAPICalled") {
|
||||||
|
const text = msg.params.args.map((a) => a.value ?? a.description ?? a.type).join(" ");
|
||||||
|
console.log(`[${msg.params.type}] ${text}`.slice(0, 3000));
|
||||||
|
}
|
||||||
|
if (msg.method === "Runtime.exceptionThrown") {
|
||||||
|
console.log("[EXCEPTION]", JSON.stringify(msg.params.exceptionDetails.exception?.description ?? msg.params.exceptionDetails.text).slice(0, 3000));
|
||||||
|
}
|
||||||
|
if (msg.method === "Log.entryAdded") {
|
||||||
|
const e = msg.params.entry;
|
||||||
|
console.log(`[log:${e.level}] ${e.text}`.slice(0, 3000));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await send("Runtime.enable");
|
||||||
|
await send("Log.enable");
|
||||||
|
await send("Network.enable");
|
||||||
|
if (cookieFile) {
|
||||||
|
const line = fs.readFileSync(cookieFile, "utf8").split("\n").map((l) => l.trim()).find((l) => l.includes("authjs.session-token"));
|
||||||
|
if (line) await send("Network.setCookies", { cookies: [{ name: "authjs.session-token", value: line.split("\t").pop(), domain: "localhost", path: "/", httpOnly: true, sameSite: "Lax" }] });
|
||||||
|
}
|
||||||
|
await send("Page.enable");
|
||||||
|
await send("Page.navigate", { url });
|
||||||
|
await new Promise((r) => setTimeout(r, 4500));
|
||||||
|
const cls = await send("Runtime.evaluate", { expression: "document.documentElement.className" });
|
||||||
|
console.log("ROOT CLASS:", cls?.result?.value);
|
||||||
|
proc.kill();
|
||||||
|
process.exit(0);
|
||||||
|
})().catch((e) => { console.error(e.message); process.exit(1); });
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
/* Faithful theme test: sets next-themes' localStorage.theme (the theme NAME)
|
||||||
|
and reloads -- exactly what the in-app theme toggle does -- then screenshots. */
|
||||||
|
const { spawn } = require("node:child_process");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const http = require("node:http");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const EDGE = "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe";
|
||||||
|
const PORT = 9341;
|
||||||
|
const BASE = "http://localhost:3210";
|
||||||
|
const OUT = path.join(__dirname, "..", "shots");
|
||||||
|
|
||||||
|
function httpJson(p) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request({ host: "127.0.0.1", port: PORT, path: p }, (res) => {
|
||||||
|
let d = ""; res.on("data", (c) => (d += c));
|
||||||
|
res.on("end", () => { try { resolve(JSON.parse(d)); } catch { resolve(d); } });
|
||||||
|
});
|
||||||
|
req.on("error", reject); req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const profile = fs.mkdtempSync("/tmp/edge-prof-");
|
||||||
|
const proc = spawn(EDGE, ["--headless=new", `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "--disable-gpu", "about:blank"], { stdio: "ignore" });
|
||||||
|
let targets = null;
|
||||||
|
for (let i = 0; i < 40; i++) { await new Promise((r) => setTimeout(r, 500)); try { targets = await httpJson("/json/list"); break; } catch {} }
|
||||||
|
if (!targets) { console.error("no targets"); process.exit(1); }
|
||||||
|
const page = targets.find((t) => t.type === "page");
|
||||||
|
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||||
|
await new Promise((r) => (ws.onopen = r));
|
||||||
|
let id = 0;
|
||||||
|
const send = (method, params = {}) => new Promise((resolve) => {
|
||||||
|
const myId = ++id; ws.send(JSON.stringify({ id: myId, method, params }));
|
||||||
|
const t = setInterval(() => { const m = ws._buf.find((b) => b.id === myId); if (m) { clearInterval(t); resolve(m.result ?? m.error); } }, 10);
|
||||||
|
});
|
||||||
|
ws._buf = [];
|
||||||
|
ws.onmessage = (m) => { const msg = JSON.parse(m.data); if (msg.id) { ws._buf.push(msg); return; }
|
||||||
|
if (msg.method === "Runtime.consoleAPICalled" && msg.params.type === "error") {
|
||||||
|
const txt = msg.params.args.map((a) => a.value ?? a.description ?? a.type).join(" ");
|
||||||
|
if (!/hydrated|React DevTools|HMR/.test(txt)) console.log("[err]", txt.slice(0, 140));
|
||||||
|
} };
|
||||||
|
|
||||||
|
await send("Runtime.enable"); await send("Page.enable"); await send("Network.enable");
|
||||||
|
const cookieFile = process.argv[2];
|
||||||
|
const cookieValue = cookieFile ? (fs.readFileSync(cookieFile, "utf8").split("\n").map((l) => l.trim()).find((l) => l.includes("authjs.session-token")) || "").split("\t").pop() : null;
|
||||||
|
// Cookie is applied per-job below (login pages need a logged-out state).
|
||||||
|
|
||||||
|
const jobs = [
|
||||||
|
{ name: "01-login-default", url: `${BASE}/login`, theme: "default", cookie: false, w: 1280, h: 800 },
|
||||||
|
{ name: "02-home-default", url: `${BASE}/`, theme: "default", cookie: true, w: 1600, h: 1000 },
|
||||||
|
{ name: "03-home-sunset", url: `${BASE}/`, theme: "sunset", cookie: true, w: 1600, h: 1000 },
|
||||||
|
{ name: "04-home-dark", url: `${BASE}/`, theme: "dark", cookie: true, w: 1600, h: 1000 },
|
||||||
|
{ name: "05-home-ocean", url: `${BASE}/`, theme: "ocean", cookie: true, w: 1600, h: 1000 },
|
||||||
|
{ name: "06-projects-dark", url: `${BASE}/projects`, theme: "dark", cookie: true, w: 1600, h: 1000 },
|
||||||
|
{ name: "07-chat-ocean", url: `${BASE}/chat`, theme: "ocean", cookie: true, w: 1600, h: 1000 },
|
||||||
|
{ name: "08-admin-default", url: `${BASE}/admin`, theme: "default", cookie: true, w: 1600, h: 1000 },
|
||||||
|
{ name: "09-login-sunset", url: `${BASE}/login`, theme: "sunset", cookie: false, w: 1280, h: 800 },
|
||||||
|
{ name: "10-home-default-mobile", url: `${BASE}/`, theme: "default", cookie: true, w: 430, h: 932 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const job of jobs) {
|
||||||
|
if (job.cookie && cookieValue) {
|
||||||
|
await send("Network.setCookies", { cookies: [{ name: "authjs.session-token", value: cookieValue, domain: "localhost", path: "/", httpOnly: true, sameSite: "Lax" }] });
|
||||||
|
} else {
|
||||||
|
await send("Network.deleteCookies", { name: "authjs.session-token", domain: "localhost" });
|
||||||
|
}
|
||||||
|
await send("Emulation.setDeviceMetricsOverride", { width: job.w, height: job.h, deviceScaleFactor: 1, mobile: job.w < 500 });
|
||||||
|
await send("Page.navigate", { url: job.url });
|
||||||
|
await new Promise((r) => setTimeout(r, 3500));
|
||||||
|
// Switch theme the way the app does: write the theme NAME to next-themes'
|
||||||
|
// storage key, then reload so it applies on a fresh (SSR+hydration) render.
|
||||||
|
await send("Runtime.evaluate", { expression: `localStorage.setItem('theme', ${JSON.stringify(job.theme)}); 'ok'` });
|
||||||
|
await send("Page.reload");
|
||||||
|
await new Promise((r) => setTimeout(r, 3800));
|
||||||
|
const htmlClass = (await send("Runtime.evaluate", { expression: `document.documentElement.className`, returnByValue: true }))?.result?.value || "";
|
||||||
|
const shot = await send("Page.captureScreenshot", { format: "png", captureBeyondViewport: false });
|
||||||
|
fs.writeFileSync(path.join(OUT, `${job.name}.png`), Buffer.from(shot.data, "base64"));
|
||||||
|
console.log(`saved ${job.name} (html=...${htmlClass.split(" ").filter(Boolean).slice(-1)[0]})`);
|
||||||
|
}
|
||||||
|
proc.kill(); process.exit(0);
|
||||||
|
})().catch((e) => { console.error(e); process.exit(1); });
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
/* Debug: step through the theme menu with full state dumps between clicks. */
|
||||||
|
const { spawn } = require("node:child_process");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const http = require("node:http");
|
||||||
|
const EDGE = "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe";
|
||||||
|
const PORT = 9356;
|
||||||
|
function httpJson(p){return new Promise((res,rej)=>{const r=http.request({host:"127.0.0.1",port:PORT,path:p},(x)=>{let d="";x.on("data",c=>d+=c);x.on("end",()=>{try{res(JSON.parse(d))}catch{res(d)}})});r.on("error",rej);r.end()})}
|
||||||
|
(async()=>{
|
||||||
|
const prof=fs.mkdtempSync(require("node:os").tmpdir()+"/edge-prof-");
|
||||||
|
const proc=spawn(EDGE,["--headless=new","--remote-debugging-port="+PORT,"--user-data-dir="+prof,"--no-first-run","--no-default-browser-check","--disable-gpu","about:blank"],{stdio:"ignore"});
|
||||||
|
let tg=null;for(let i=0;i<40;i++){await new Promise(r=>setTimeout(r,500));try{tg=await httpJson("/json/list");break}catch{}}
|
||||||
|
const page=tg.find(t=>t.type==="page");
|
||||||
|
const ws=new WebSocket(page.webSocketDebuggerUrl);
|
||||||
|
await new Promise(r=>ws.onopen=r);let id=0;
|
||||||
|
const send=(m,p={})=>new Promise(res=>{const i2=++id;ws.send(JSON.stringify({id:i2,method:m,params:p}));const t=setInterval(()=>{const f=ws._buf.find(b=>b.id===i2);if(f){clearInterval(t);res(f.result??f.error)}},10)});
|
||||||
|
ws._buf=[];const errs=[];
|
||||||
|
ws.onmessage=(m)=>{const j=JSON.parse(m.data);if(j.id){ws._buf.push(j);return;}
|
||||||
|
if(j.method==="Runtime.consoleAPICalled"&&j.params.type==="error"){errs.push(j.params.args.map(a=>(a.value??a.description??"")).join(" ").slice(0,160));}
|
||||||
|
if(j.method==="Runtime.exceptionThrown"){errs.push("EXC: "+(j.params.exceptionDetails?.exception?.description||"").slice(0,120));}};
|
||||||
|
await send("Runtime.enable");await send("Page.enable");await send("Network.enable");
|
||||||
|
const cv=fs.readFileSync(process.argv[2],"utf8").split("\n").map(l=>l.trim()).find(l=>l.includes("authjs.session-token")).split("\t").pop();
|
||||||
|
await send("Network.setCookies",{cookies:[{name:"authjs.session-token",value:cv,domain:"localhost",path:"/",httpOnly:true,sameSite:"Lax"}]});
|
||||||
|
await send("Emulation.setDeviceMetricsOverride",{width:1440,height:900,deviceScaleFactor:1});
|
||||||
|
|
||||||
|
const ev=(expr)=>send("Runtime.evaluate",{expression:expr,returnByValue:true,awaitPromise:true}).then(r=>r.result?.value ?? r.exceptionDetails?.exception?.description);
|
||||||
|
const dump=async(label)=>console.log(label, JSON.stringify(await ev(`(()=>{
|
||||||
|
const btns=Array.from(document.querySelectorAll("button")).map(b=>(b.textContent||"").trim()).filter(t=>["Theme","Default","Sunset","Dark","Ocean","Match system"].includes(t));
|
||||||
|
return {html:document.documentElement.className.split(" ").filter(Boolean).slice(-1).join(","), ls:localStorage.getItem("theme"), btns, menuOpen:!!document.querySelector('[data-slot="dropdown-menu-content"]')};
|
||||||
|
})()`)));
|
||||||
|
const click=async(x,y)=>{await send("Input.dispatchMouseEvent",{type:"mousePressed",x,y,button:"left",clickCount:1});await send("Input.dispatchMouseEvent",{type:"mouseReleased",x,y,button:"left",clickCount:1});};
|
||||||
|
const center=async(name)=>{const v=await ev(`(()=>{const b=Array.from(document.querySelectorAll("button")).find(x=>(x.textContent||"").trim()==="${name}");if(!b)return null;const r=b.getBoundingClientRect();return JSON.stringify({x:Math.round(r.x+12),y:Math.round(r.y+r.height/2)})})()`);return v?JSON.parse(v):null;};
|
||||||
|
|
||||||
|
await send("Page.navigate",{url:"http://localhost:3210/"});
|
||||||
|
await new Promise(r=>setTimeout(r,9000));
|
||||||
|
console.log("PAGE", JSON.stringify(await ev("JSON.stringify({title:document.title,bodyLen:document.body.innerText.length,btnCount:document.querySelectorAll('button').length})")));
|
||||||
|
|
||||||
|
await dump("S0 initial ");
|
||||||
|
let p=await center("Theme"); if(!p){console.log("no Theme btn");proc.kill();process.exit(1);}
|
||||||
|
await click(p.x,p.y); await new Promise(r=>setTimeout(r,600));
|
||||||
|
await dump("S1 after open ");
|
||||||
|
p=await center("Default"); console.log(" default btn at", JSON.stringify(p));
|
||||||
|
await click(p.x,p.y); await new Promise(r=>setTimeout(r,900));
|
||||||
|
await dump("S2 after Default");
|
||||||
|
p=await center("Theme"); if(!p){console.log("no Theme btn #2");proc.kill();process.exit(1);}
|
||||||
|
await click(p.x,p.y); await new Promise(r=>setTimeout(r,600));
|
||||||
|
await dump("S3 after open2 ");
|
||||||
|
p=await center("Ocean"); console.log(" ocean btn at", JSON.stringify(p));
|
||||||
|
await click(p.x,p.y); await new Promise(r=>setTimeout(r,900));
|
||||||
|
await dump("S4 after Ocean ");
|
||||||
|
const real=errs.filter(e=>!/favicon|manifest|icon|404/i.test(e));
|
||||||
|
console.log("ERRORS:", real.length); real.slice(0,6).forEach(e=>console.log(" -",e));
|
||||||
|
proc.kill();process.exit(0);
|
||||||
|
})().catch(e=>{console.error("FAIL",e.message);process.exit(1)});
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
const { spawn } = require("node:child_process");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const http = require("node:http");
|
||||||
|
const EDGE = "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe";
|
||||||
|
const PORT = 9339;
|
||||||
|
function httpJson(path) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request({ host: "127.0.0.1", port: PORT, path }, (res) => {
|
||||||
|
let d = ""; res.on("data", (c) => (d += c));
|
||||||
|
res.on("end", () => { try { resolve(JSON.parse(d)); } catch { resolve(d); } });
|
||||||
|
});
|
||||||
|
req.on("error", reject); req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
(async () => {
|
||||||
|
const profile = fs.mkdtempSync("/tmp/edge-prof-");
|
||||||
|
const proc = spawn(EDGE, ["--headless=new", `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "--disable-gpu", "about:blank"], { stdio: "ignore" });
|
||||||
|
let targets = null;
|
||||||
|
for (let i = 0; i < 40; i++) { await new Promise((r) => setTimeout(r, 500)); try { targets = await httpJson("/json/list"); break; } catch {} }
|
||||||
|
if (!targets) { console.error("no targets"); process.exit(1); }
|
||||||
|
const page = targets.find((t) => t.type === "page");
|
||||||
|
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||||
|
await new Promise((r) => (ws.onopen = r));
|
||||||
|
let id = 0;
|
||||||
|
const send = (method, params = {}) => new Promise((resolve) => {
|
||||||
|
const myId = ++id; ws.send(JSON.stringify({ id: myId, method, params }));
|
||||||
|
const t = setInterval(() => { const m = ws._buf.find((b) => b.id === myId); if (m) { clearInterval(t); resolve(m.result ?? m.error); } }, 10);
|
||||||
|
});
|
||||||
|
ws._buf = [];
|
||||||
|
ws.onmessage = (m) => { const msg = JSON.parse(m.data); if (msg.id) { ws._buf.push(msg); return; }
|
||||||
|
if (msg.method === "Runtime.consoleAPICalled") console.log("[c]", msg.params.args.map((a) => a.value ?? a.description ?? a.type).join(" ").slice(0, 120)); };
|
||||||
|
await send("Runtime.enable"); await send("Network.enable");
|
||||||
|
const cookieFile = process.argv[2];
|
||||||
|
if (cookieFile) { const line = fs.readFileSync(cookieFile, "utf8").split("\n").map((l) => l.trim()).find((l) => l.includes("authjs.session-token"));
|
||||||
|
if (line) await send("Network.setCookies", { cookies: [{ name: "authjs.session-token", value: line.split("\t").pop(), domain: "localhost", path: "/", httpOnly: true, sameSite: "Lax" }] }); }
|
||||||
|
await send("Page.enable");
|
||||||
|
await send("Page.navigate", { url: "http://localhost:3210/" });
|
||||||
|
await new Promise((r) => setTimeout(r, 4000));
|
||||||
|
const theme = process.argv[3] || "dark";
|
||||||
|
await send("Runtime.evaluate", { expression: `document.documentElement.className = ${JSON.stringify(theme)}` });
|
||||||
|
await new Promise((r) => setTimeout(r, 1600));
|
||||||
|
const res = await send("Runtime.evaluate", { expression: `(() => {
|
||||||
|
const card = document.querySelector('[aria-label^="Drag to move"]')?.closest('[class*="rounded-2xl"]');
|
||||||
|
return JSON.stringify({
|
||||||
|
htmlClassTail: document.documentElement.className.split(" ").filter(Boolean).slice(-2),
|
||||||
|
inlineStyle: card ? card.getAttribute("style") : null,
|
||||||
|
computedBg: card ? getComputedStyle(card).backgroundColor : null,
|
||||||
|
varCard: getComputedStyle(document.documentElement).getPropertyValue('--card').trim(),
|
||||||
|
});
|
||||||
|
})()`, returnByValue: true });
|
||||||
|
console.log(res?.result?.value);
|
||||||
|
proc.kill(); process.exit(0);
|
||||||
|
})().catch((e) => { console.error(e.message); process.exit(1); });
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
/* FINAL CHECK: login via UI, verify Home renders, cycle all 4 themes live,
|
||||||
|
* confirm card colors change instantly, zero console errors, screenshots. */
|
||||||
|
const { spawn } = require("node:child_process");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const http = require("node:http");
|
||||||
|
const EDGE = "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe";
|
||||||
|
const PORT = 9380;
|
||||||
|
function httpJson(p) {
|
||||||
|
return new Promise((res, rej) => {
|
||||||
|
const r = http.request({ host: "127.0.0.1", port: PORT, path: p }, (x) => {
|
||||||
|
let d = ""; x.on("data", (c) => { d += c; });
|
||||||
|
x.on("end", () => { try { res(JSON.parse(d)); } catch (e) { res(d); } });
|
||||||
|
});
|
||||||
|
r.on("error", rej); r.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
(async () => {
|
||||||
|
const prof = fs.mkdtempSync(require("node:os").tmpdir() + "/edge-prof-");
|
||||||
|
const proc = spawn(EDGE, ["--headless=new", `--remote-debugging-port=${PORT}`, `--user-data-dir=${prof}`, "--no-first-run", "--no-default-browser-check", "--disable-gpu", "about:blank"], { stdio: "ignore" });
|
||||||
|
let tg = null;
|
||||||
|
for (let i = 0; i < 40; i++) { await new Promise(r => setTimeout(r, 500)); try { tg = await httpJson("/json/list"); break; } catch (e) {} }
|
||||||
|
const page = tg.find(t => t.type === "page");
|
||||||
|
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||||
|
await new Promise(r => (ws.onopen = r));
|
||||||
|
let id = 0;
|
||||||
|
const send = (m, p = {}) => new Promise(res => {
|
||||||
|
const i2 = ++id; ws.send(JSON.stringify({ id: i2, method: m, params: p }));
|
||||||
|
const t = setInterval(() => { const f = ws._buf.find(b => b.id === i2); if (f) { clearInterval(t); res(f.result ?? f.error); } }, 10);
|
||||||
|
});
|
||||||
|
ws._buf = [];
|
||||||
|
const errors = [];
|
||||||
|
ws.onmessage = (m) => {
|
||||||
|
const j = JSON.parse(m.data);
|
||||||
|
if (j.id) { ws._buf.push(j); return; }
|
||||||
|
if (j.method === "Runtime.consoleAPICalled" && j.params.type === "error") {
|
||||||
|
errors.push(j.params.args.map(a => (a.value ?? a.description ?? "")).join(" ").slice(0, 140));
|
||||||
|
}
|
||||||
|
if (j.method === "Runtime.exceptionThrown") {
|
||||||
|
errors.push("EXC: " + (j.params.exceptionDetails?.exception?.description || j.params.exceptionDetails?.text || "").slice(0, 140));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await send("Runtime.enable"); await send("Page.enable"); await send("Network.enable");
|
||||||
|
await send("Emulation.setDeviceMetricsOverride", { width: 1440, height: 900, deviceScaleFactor: 1 });
|
||||||
|
|
||||||
|
const ev = (e) => send("Runtime.evaluate", { expression: e, returnByValue: true }).then(r => (r.result ? r.result.value : "ERR"));
|
||||||
|
const click = async (x, y) => {
|
||||||
|
await send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", clickCount: 1 });
|
||||||
|
await send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", clickCount: 1 });
|
||||||
|
};
|
||||||
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||||
|
const shot = async (name) => { const s = await send("Page.captureScreenshot", { format: "png" }); fs.writeFileSync("shots/" + name, Buffer.from(s.data, "base64")); };
|
||||||
|
|
||||||
|
// --- 1. login -----------------------------------------------------------
|
||||||
|
await send("Page.navigate", { url: "http://localhost:3210/" });
|
||||||
|
await sleep(4000);
|
||||||
|
const fill = (sel, v) => ev(`(() => { const el = document.querySelector(${JSON.stringify(sel)}); if (!el) return false; const set = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set; set.call(el, ${JSON.stringify(v)}); el.dispatchEvent(new Event("input", { bubbles: true })); return true; })()`);
|
||||||
|
console.log("1. login form found:", await ev("!!document.querySelector('input[type=email]') && !!document.querySelector('input[type=password]')"));
|
||||||
|
await fill("input[type=email]", "dev@example.com");
|
||||||
|
await fill("input[type=password]", "password123");
|
||||||
|
const sub = JSON.parse(await ev(`(() => { const b = document.querySelector("form button[type=submit]") || document.querySelector("form button"); const r = b.getBoundingClientRect(); return JSON.stringify({ x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }); })()`));
|
||||||
|
await click(sub.x, sub.y);
|
||||||
|
let home = false;
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
await sleep(1000);
|
||||||
|
const url = await ev("location.href");
|
||||||
|
const btns = await ev("document.querySelectorAll('button').length");
|
||||||
|
if (url.endsWith("3210/") && btns > 8) { home = true; break; }
|
||||||
|
}
|
||||||
|
console.log("2. logged in & on Home:", home, "| URL:", await ev("location.href"), "| buttons:", await ev("document.querySelectorAll('button').length"));
|
||||||
|
console.log("3. board rendered:", await ev(`(() => { const lanes = document.querySelectorAll('[data-slot="dropdown-menu-trigger"], [class*="lane"]').length; const groups = Array.from(document.querySelectorAll("h3")).length; return "headings=" + groups; })()`));
|
||||||
|
|
||||||
|
// --- 2. helpers ---------------------------------------------------------
|
||||||
|
const cardState = () => ev(`(() => {
|
||||||
|
const cs = Array.from(document.querySelectorAll('[class*=rounded-2xl]')).filter(c => /background-color/.test(c.getAttribute('style') || ''));
|
||||||
|
if (!cs.length) return { cards: 0 };
|
||||||
|
const s = cs[0].getAttribute('style');
|
||||||
|
return {
|
||||||
|
cards: cs.length,
|
||||||
|
fill: (s.match(/background-color:([^;]+)/) || [])[1],
|
||||||
|
stroke: (s.match(/border-color:([^;]+)/) || [])[1],
|
||||||
|
html: document.documentElement.className.split(' ').filter(Boolean).slice(-1).join(','),
|
||||||
|
bodyBg: getComputedStyle(document.body).backgroundColor,
|
||||||
|
};
|
||||||
|
})()`);
|
||||||
|
|
||||||
|
const selectTheme = async (name) => {
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
|
const v = await ev(`(() => { const b = Array.from(document.querySelectorAll('button')).find(x => (x.textContent || '').trim() === 'Theme'); if (!b) return null; const r = b.getBoundingClientRect(); return JSON.stringify({ x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }); })()`);
|
||||||
|
if (!v) { await sleep(400); continue; }
|
||||||
|
const tp = JSON.parse(v); await click(tp.x, tp.y);
|
||||||
|
let labels = null;
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
await sleep(150);
|
||||||
|
labels = await ev(`(() => { const m = document.querySelector('[data-slot="dropdown-menu-content"]'); if (!m || m.querySelectorAll('button').length < 4) return null; return JSON.stringify(Array.from(m.querySelectorAll('button')).map(b => (b.textContent || '').trim())); })()`);
|
||||||
|
if (labels) break;
|
||||||
|
}
|
||||||
|
if (!labels) { await sleep(500); continue; }
|
||||||
|
const idx = JSON.parse(labels).indexOf(name);
|
||||||
|
if (idx === -1) return "option missing";
|
||||||
|
const p = JSON.parse(await ev(`(() => { const m = document.querySelector('[data-slot="dropdown-menu-content"]'); const b = m.querySelectorAll('button')[${idx}]; const r = b.getBoundingClientRect(); return JSON.stringify({ x: Math.round(r.x + 12), y: Math.round(r.y + r.height / 2) }); })()`));
|
||||||
|
await click(p.x, p.y); await sleep(900);
|
||||||
|
return "ok";
|
||||||
|
}
|
||||||
|
return "failed after retries";
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 3. cycle themes: card fill must match light/dark variant live ---
|
||||||
|
const seq = ["Sunset", "Default", "Dark", "Ocean", "Sunset"];
|
||||||
|
const expectedFill = (theme) =>
|
||||||
|
theme === "Dark" || theme === "Ocean" ? "#3a2320" /* dark tint (softDark) */ : "#fae4e1"; /* light tint (soft) */;
|
||||||
|
let prev = await cardState();
|
||||||
|
let allOk = true;
|
||||||
|
for (const name of seq) {
|
||||||
|
const r = await selectTheme(name);
|
||||||
|
await sleep(300);
|
||||||
|
const now = await cardState();
|
||||||
|
const correct = r === "ok" && now.fill && now.fill.includes(expectedFill(name)) && prev.fill !== undefined;
|
||||||
|
// a live switch = the html class changed and the fill is the right variant for the new theme
|
||||||
|
if (!correct) allOk = false;
|
||||||
|
console.log(`4. ${name.padEnd(7)}:`, r, "| fill correct for theme:", now.fill.includes(expectedFill(name)), "| fill:", now.fill && now.fill.trim().slice(0, 40), "| html:", now.html);
|
||||||
|
prev = now;
|
||||||
|
}
|
||||||
|
console.log("5. ALL theme switches correct & live:", allOk);
|
||||||
|
|
||||||
|
// --- 4. other pages smoke test ------------------------------------------
|
||||||
|
const pages = ["/projects", "/chat", "/admin"];
|
||||||
|
let adminBadgeOk = null;
|
||||||
|
for (const p of pages) {
|
||||||
|
const before = errors.length;
|
||||||
|
await ev(`location.href = ${JSON.stringify("http://localhost:3210" + p)}; true`);
|
||||||
|
await sleep(3500);
|
||||||
|
const ok = await ev(`document.body.innerText.length > 50 && document.querySelectorAll('button').length > 3`);
|
||||||
|
const newErr = errors.length - before;
|
||||||
|
console.log(`7. page ${p}: renders=${!!ok}, new console errors=${newErr}`);
|
||||||
|
if (p === "/admin") {
|
||||||
|
// role badge must be visible & its menu must open on a trusted click
|
||||||
|
await ev(`(() => { const b = Array.from(document.querySelectorAll('button')).find(x => /Change role/.test(x.getAttribute('aria-label') || '')); if (b) b.scrollIntoView({ block: 'center' }); return true; })()`);
|
||||||
|
await sleep(400);
|
||||||
|
const badgePos = await ev(`(() => { const b = Array.from(document.querySelectorAll('button')).find(x => /Change role/.test(x.getAttribute('aria-label') || '')); if (!b) return null; const r = b.getBoundingClientRect(); return JSON.stringify({ x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }); })()`);
|
||||||
|
if (badgePos) {
|
||||||
|
const bp = JSON.parse(badgePos);
|
||||||
|
await click(bp.x, bp.y);
|
||||||
|
let opened = false;
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await sleep(150);
|
||||||
|
opened = !!(await ev(`document.querySelector('[data-slot="dropdown-menu-content"]')`));
|
||||||
|
if (opened) break;
|
||||||
|
}
|
||||||
|
adminBadgeOk = opened;
|
||||||
|
console.log("7b. admin role-badge menu opens:", opened);
|
||||||
|
await ev(`document.body.click(); true`);
|
||||||
|
await sleep(300);
|
||||||
|
} else {
|
||||||
|
adminBadgeOk = false;
|
||||||
|
console.log("7b. admin role badge not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5. final state + errors --------------------------------------------
|
||||||
|
await ev(`location.href = "http://localhost:3210/"; true`);
|
||||||
|
await sleep(4000);
|
||||||
|
await shot("final-home.png");
|
||||||
|
const realErrors = errors.filter(e => !/favicon|manifest|icon|404|DevTools|HMR/i.test(e));
|
||||||
|
console.log("8. CONSOLE ERRORS (filtered):", realErrors.length);
|
||||||
|
realErrors.slice(0, 8).forEach(e => console.log(" -", e));
|
||||||
|
console.log("9. hydration warnings:", errors.filter(e => /hydrat/i.test(e)).length);
|
||||||
|
console.log("10. Base UI crashes:", errors.filter(e => /Base UI/i.test(e)).length);
|
||||||
|
proc.kill();
|
||||||
|
const pass = allOk && realErrors.length === 0 && home && adminBadgeOk === true;
|
||||||
|
console.log(pass ? "\nFINAL RESULT: ALL PASS ✔" : "\nFINAL RESULT: FAIL");
|
||||||
|
process.exit(pass ? 0 : 2);
|
||||||
|
})().catch(e => { console.error("FAIL", e.message); process.exit(1); });
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
/* Live-switch test with waits + evidence screenshots. */
|
||||||
|
const { spawn } = require("node:child_process");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const http = require("node:http");
|
||||||
|
const EDGE = "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe";
|
||||||
|
const PORT = 9368;
|
||||||
|
function httpJson(p){
|
||||||
|
return new Promise((res, rej) => {
|
||||||
|
const r = http.request({ host: "127.0.0.1", port: PORT, path: p }, (x) => {
|
||||||
|
let d = "";
|
||||||
|
x.on("data", (c) => { d += c; });
|
||||||
|
x.on("end", () => { try { res(JSON.parse(d)); } catch (e) { res(d); } });
|
||||||
|
});
|
||||||
|
r.on("error", rej);
|
||||||
|
r.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
(async () => {
|
||||||
|
const prof = fs.mkdtempSync(require("node:os").tmpdir() + "/edge-prof-");
|
||||||
|
const proc = spawn(EDGE, ["--headless=new", `--remote-debugging-port=${PORT}`, `--user-data-dir=${prof}`, "--no-first-run", "--no-default-browser-check", "--disable-gpu", "about:blank"], { stdio: "ignore" });
|
||||||
|
let tg = null;
|
||||||
|
for (let i = 0; i < 40; i++) { await new Promise(r => setTimeout(r, 500)); try { tg = await httpJson("/json/list"); break; } catch (e) {} }
|
||||||
|
const page = tg.find(t => t.type === "page");
|
||||||
|
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||||
|
await new Promise(r => (ws.onopen = r));
|
||||||
|
let id = 0;
|
||||||
|
const send = (m, p = {}) => new Promise(res => {
|
||||||
|
const i2 = ++id;
|
||||||
|
ws.send(JSON.stringify({ id: i2, method: m, params: p }));
|
||||||
|
const t = setInterval(() => { const f = ws._buf.find(b => b.id === i2); if (f) { clearInterval(t); res(f.result ?? f.error); } }, 10);
|
||||||
|
});
|
||||||
|
ws._buf = [];
|
||||||
|
const errs = [];
|
||||||
|
ws.onmessage = (m) => {
|
||||||
|
const j = JSON.parse(m.data);
|
||||||
|
if (j.id) { ws._buf.push(j); return; }
|
||||||
|
if (j.method === "Runtime.consoleAPICalled" && j.params.type === "error") errs.push(j.params.args.map(a => (a.value ?? a.description ?? "")).join(" ").slice(0, 120));
|
||||||
|
if (j.method === "Runtime.exceptionThrown") errs.push("EXC: " + (j.params.exceptionDetails?.exception?.description || "").slice(0, 120));
|
||||||
|
};
|
||||||
|
await send("Runtime.enable");
|
||||||
|
await send("Page.enable");
|
||||||
|
await send("Network.enable");
|
||||||
|
const cv = fs.readFileSync(process.argv[1], "utf8").split("\n").map(l => l.trim()).find(l => l.includes("authjs.session-token")).split("\t").pop();
|
||||||
|
await send("Network.setCookies", { cookies: [{ name: "authjs.session-token", value: cv, domain: "localhost", path: "/", httpOnly: true, sameSite: "Lax" }] });
|
||||||
|
await send("Emulation.setDeviceMetricsOverride", { width: 1440, height: 900, deviceScaleFactor: 1 });
|
||||||
|
await send("Page.navigate", { url: "http://localhost:3210/" });
|
||||||
|
let ready = false;
|
||||||
|
for (let i = 0; i < 45; i++) {
|
||||||
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
ready = await send("Runtime.evaluate", { expression: "document.querySelectorAll('button').length > 5", returnByValue: true }).then(r => r.result?.value);
|
||||||
|
if (ready) break;
|
||||||
|
}
|
||||||
|
console.log("page ready:", ready);
|
||||||
|
|
||||||
|
const ev = (expr) => send("Runtime.evaluate", { expression: expr, returnByValue: true }).then(r => r.result?.value);
|
||||||
|
const click = async (x, y) => {
|
||||||
|
await send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", clickCount: 1 });
|
||||||
|
await send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", clickCount: 1 });
|
||||||
|
};
|
||||||
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||||
|
const cardStyle = async () => ev(`(() => {
|
||||||
|
const c = Array.from(document.querySelectorAll('[class*=rounded-2xl]')).find(x => /background-color/.test(x.getAttribute('style') || ''));
|
||||||
|
return c ? c.getAttribute('style').slice(0, 150) : 'no-card';
|
||||||
|
})()`);
|
||||||
|
const shot = async (name) => {
|
||||||
|
const s = await send("Page.captureScreenshot", { format: "png" });
|
||||||
|
fs.writeFileSync("shots/" + name, Buffer.from(s.data, "base64"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectTheme = async (name) => {
|
||||||
|
const v = await ev(`(() => {
|
||||||
|
const b = Array.from(document.querySelectorAll('button')).find(x => (x.textContent || '').trim() === 'Theme');
|
||||||
|
if (!b) return null;
|
||||||
|
const r = b.getBoundingClientRect();
|
||||||
|
return JSON.stringify({ x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
|
||||||
|
})()`);
|
||||||
|
if (!v) return "no theme button";
|
||||||
|
const tp = JSON.parse(v);
|
||||||
|
await click(tp.x, tp.y);
|
||||||
|
let labels = null;
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
await sleep(150);
|
||||||
|
labels = await ev(`(() => {
|
||||||
|
const m = document.querySelector('[data-slot="dropdown-menu-content"]');
|
||||||
|
if (!m || m.querySelectorAll('button').length < 4) return null;
|
||||||
|
return JSON.stringify(Array.from(m.querySelectorAll('button')).map(b => (b.textContent || '').trim()));
|
||||||
|
})()`);
|
||||||
|
if (labels) break;
|
||||||
|
}
|
||||||
|
if (!labels) return "menu never populated";
|
||||||
|
const idx = JSON.parse(labels).indexOf(name);
|
||||||
|
if (idx === -1) return "option missing: " + labels;
|
||||||
|
const p = JSON.parse(await ev(`(() => {
|
||||||
|
const m = document.querySelector('[data-slot="dropdown-menu-content"]');
|
||||||
|
const b = m.querySelectorAll('button')[${idx}];
|
||||||
|
const r = b.getBoundingClientRect();
|
||||||
|
return JSON.stringify({ x: Math.round(r.x + 12), y: Math.round(r.y + r.height / 2) });
|
||||||
|
})()`));
|
||||||
|
await click(p.x, p.y);
|
||||||
|
await sleep(900);
|
||||||
|
return "ok";
|
||||||
|
};
|
||||||
|
|
||||||
|
const A = await cardStyle(); console.log("A dark :", A); await shot("12-live-dark.png");
|
||||||
|
console.log("-> Sunset :", await selectTheme("Sunset"));
|
||||||
|
const B = await cardStyle(); console.log("B sunset :", B); await shot("13-live-sunset.png");
|
||||||
|
console.log("-> Ocean :", await selectTheme("Ocean"));
|
||||||
|
const C = await cardStyle(); console.log("C ocean :", C); await shot("14-live-ocean.png");
|
||||||
|
console.log("-> Default:", await selectTheme("Default"));
|
||||||
|
const D = await cardStyle(); console.log("D default :", D); await shot("15-live-default.png");
|
||||||
|
console.log("CHANGED A!=B:", A !== B, " B!=C:", B !== C, " C!=D:", C !== D);
|
||||||
|
const real = errs.filter(e => !/favicon|manifest|icon|404/i.test(e));
|
||||||
|
console.log("CONSOLE ERRORS:", real.length);
|
||||||
|
real.slice(0, 4).forEach(e => console.log(" -", e));
|
||||||
|
proc.kill();
|
||||||
|
process.exit(0);
|
||||||
|
})().catch(e => { console.error("FAIL", e.message); process.exit(1); });
|
||||||
|
|
@ -0,0 +1,502 @@
|
||||||
|
# next-themes  
|
||||||
|
|
||||||
|
An abstraction for themes in your Next.js app.
|
||||||
|
|
||||||
|
- ✅ Perfect dark mode in 2 lines of code
|
||||||
|
- ✅ System setting with prefers-color-scheme
|
||||||
|
- ✅ Themed browser UI with color-scheme
|
||||||
|
- ✅ No flash on load (both SSR and SSG)
|
||||||
|
- ✅ Sync theme across tabs and windows
|
||||||
|
- ✅ Disable flashing when changing themes
|
||||||
|
- ✅ Force pages to specific themes
|
||||||
|
- ✅ Class or data attribute selector
|
||||||
|
- ✅ `useTheme` hook
|
||||||
|
|
||||||
|
Check out the [Live Example](https://next-themes-example.vercel.app/) to try it for yourself.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ npm install next-themes
|
||||||
|
# or
|
||||||
|
$ yarn add next-themes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use
|
||||||
|
|
||||||
|
You'll need a [Custom `App`](https://nextjs.org/docs/advanced-features/custom-app) to use next-themes. The simplest `_app` looks like this:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// pages/_app.js
|
||||||
|
|
||||||
|
function MyApp({ Component, pageProps }) {
|
||||||
|
return <Component {...pageProps} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MyApp
|
||||||
|
```
|
||||||
|
|
||||||
|
Adding dark mode support takes 2 lines of code:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { ThemeProvider } from 'next-themes'
|
||||||
|
|
||||||
|
function MyApp({ Component, pageProps }) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<Component {...pageProps} />
|
||||||
|
</ThemeProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MyApp
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it, your Next.js app fully supports dark mode, including System preference with `prefers-color-scheme`. The theme is also immediately synced between tabs. By default, next-themes modifies the `data-theme` attribute on the `html` element, which you can easily use to style your app:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* Your default theme */
|
||||||
|
--background: white;
|
||||||
|
--foreground: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] {
|
||||||
|
--background: black;
|
||||||
|
--foreground: white;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### useTheme
|
||||||
|
|
||||||
|
Your UI will need to know the current theme and be able to change it. The `useTheme` hook provides theme information:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
|
|
||||||
|
const ThemeChanger = () => {
|
||||||
|
const { theme, setTheme } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
The current theme is: {theme}
|
||||||
|
<button onClick={() => setTheme('light')}>Light Mode</button>
|
||||||
|
<button onClick={() => setTheme('dark')}>Dark Mode</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Warning!** The above code is hydration _unsafe_ and will throw a hydration mismatch warning when rendering with SSG or SSR. This is because we cannot know the `theme` on the server, so it will always be `undefined` until mounted on the client.
|
||||||
|
>
|
||||||
|
> You should delay rendering any theme toggling UI until mounted on the client. See the [example](#avoid-hydration-mismatch).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Let's dig into the details.
|
||||||
|
|
||||||
|
### ThemeProvider
|
||||||
|
|
||||||
|
All your theme configuration is passed to ThemeProvider.
|
||||||
|
|
||||||
|
- `storageKey = 'theme'`: Key used to store theme setting in localStorage
|
||||||
|
- `defaultTheme = 'system'`: Default theme name (for v0.0.12 and lower the default was `light`). If `enableSystem` is false, the default theme is `light`
|
||||||
|
- `forcedTheme`: Forced theme name for the current page (does not modify saved theme settings)
|
||||||
|
- `enableSystem = true`: Whether to switch between `dark` and `light` based on `prefers-color-scheme`
|
||||||
|
- `enableColorScheme = true`: Whether to indicate to browsers which color scheme is used (dark or light) for built-in UI like inputs and buttons
|
||||||
|
- `disableTransitionOnChange = false`: Optionally disable all CSS transitions when switching themes ([example](#disable-transitions-on-theme-change))
|
||||||
|
- `themes = ['light', 'dark']`: List of theme names
|
||||||
|
- `attribute = 'data-theme'`: HTML attribute modified based on the active theme
|
||||||
|
- accepts `class` and `data-*` (meaning any data attribute, `data-mode`, `data-color`, etc.) ([example](#class-instead-of-data-attribute))
|
||||||
|
- `value`: Optional mapping of theme name to attribute value
|
||||||
|
- value is an `object` where key is the theme name and value is the attribute value ([example](#differing-dom-attribute-and-theme-name))
|
||||||
|
- `nonce`: Optional nonce passed to the injected `script` tag, used to allow-list the next-themes script in your CSP
|
||||||
|
|
||||||
|
### useTheme
|
||||||
|
|
||||||
|
useTheme takes no parameters, but returns:
|
||||||
|
|
||||||
|
- `theme`: Active theme name
|
||||||
|
- `setTheme(name)`: Function to update the theme
|
||||||
|
- `forcedTheme`: Forced page theme or falsy. If `forcedTheme` is set, you should disable any theme switching UI
|
||||||
|
- `resolvedTheme`: Returns the effective theme color of the page.
|
||||||
|
- If `enableSystem` is true and the active theme is "system", this returns whether the system preference resolved to "dark" or "light".
|
||||||
|
- If `forcedTheme` is set, the name of the forced theme is returned.
|
||||||
|
- Otherwise identical to `theme`.
|
||||||
|
- `systemTheme`: If `enableSystem` is true, represents the System theme preference ("dark" or "light"), regardless what the active theme is
|
||||||
|
- `themes`: The list of themes passed to `ThemeProvider` (with "system" appended, if `enableSystem` is true)
|
||||||
|
|
||||||
|
Not too bad, right? Let's see how to use these properties with examples:
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
The [Live Example](https://next-themes-example.vercel.app/) shows next-themes in action, with dark, light, system themes and pages with forced themes.
|
||||||
|
|
||||||
|
### Use System preference by default
|
||||||
|
|
||||||
|
The `defaultTheme` is "light". If you want to respect the System preference instead, set it to "system":
|
||||||
|
|
||||||
|
```js
|
||||||
|
<ThemeProvider defaultTheme="system">
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ignore System preference
|
||||||
|
|
||||||
|
If you don't want a System theme, disable it via `enableSystem`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
<ThemeProvider enableSystem={false}>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Class instead of data attribute
|
||||||
|
|
||||||
|
If your Next.js app uses a class to style the page based on the theme, change the attribute prop to `class`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
<ThemeProvider attribute="class">
|
||||||
|
```
|
||||||
|
|
||||||
|
Now, setting the theme to "dark" will set `class="dark"` on the `html` element.
|
||||||
|
|
||||||
|
### Force page to a theme
|
||||||
|
|
||||||
|
Let's say your cool new marketing page is dark mode only. The page should always use the dark theme, and changing the theme should have no effect. To force a theme on your Next.js pages, simply set a variable on the page component:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// pages/awesome-page.js
|
||||||
|
|
||||||
|
const Page = () => { ... }
|
||||||
|
Page.theme = 'dark'
|
||||||
|
export default Page
|
||||||
|
```
|
||||||
|
|
||||||
|
In your `_app`, read the variable and pass it to ThemeProvider:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function MyApp({ Component, pageProps }) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider forcedTheme={Component.theme || null}>
|
||||||
|
<Component {...pageProps} />
|
||||||
|
</ThemeProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Done! Your page is always dark theme (regardless of user preference), and calling `setTheme` from `useTheme` is now a no-op. However, you should make sure to disable any of your UI that would normally change the theme:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { forcedTheme } = useTheme()
|
||||||
|
|
||||||
|
// Theme is forced, we shouldn't allow user to change the theme
|
||||||
|
const disabled = !!forcedTheme
|
||||||
|
```
|
||||||
|
|
||||||
|
### Disable transitions on theme change
|
||||||
|
|
||||||
|
I wrote about [this technique here](https://paco.sh/blog/disable-theme-transitions). We can forcefully disable all CSS transitions before the theme is changed, and re-enable them immediately afterwards. This ensures your UI with different transition durations won't feel inconsistent when changing the theme.
|
||||||
|
|
||||||
|
To enable this behavior, pass the `disableTransitionOnChange` prop:
|
||||||
|
|
||||||
|
```js
|
||||||
|
<ThemeProvider disableTransitionOnChange>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Differing DOM attribute and theme name
|
||||||
|
|
||||||
|
The name of the active theme is used as both the localStorage value and the value of the DOM attribute. If the theme name is "pink", localStorage will contain `theme=pink` and the DOM will be `data-theme="pink"`. You **cannot** modify the localStorage value, but you **can** modify the DOM value.
|
||||||
|
|
||||||
|
If we want the DOM to instead render `data-theme="my-pink-theme"` when the theme is "pink", pass the `value` prop:
|
||||||
|
|
||||||
|
```js
|
||||||
|
<ThemeProvider value={{ pink: 'my-pink-theme' }}>
|
||||||
|
```
|
||||||
|
|
||||||
|
Done! To be extra clear, this affects only the DOM. Here's how all the values will look:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { theme } = useTheme()
|
||||||
|
// => "pink"
|
||||||
|
|
||||||
|
localStorage.getItem('theme')
|
||||||
|
// => "pink"
|
||||||
|
|
||||||
|
document.documentElement.getAttribute('data-theme')
|
||||||
|
// => "my-pink-theme"
|
||||||
|
```
|
||||||
|
|
||||||
|
### More than light and dark mode
|
||||||
|
|
||||||
|
next-themes is designed to support any number of themes! Simply pass a list of themes:
|
||||||
|
|
||||||
|
```js
|
||||||
|
<ThemeProvider themes={['pink', 'red', 'blue']}>
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note!** When you pass `themes`, the default set of themes ("light" and "dark") are overridden. Make sure you include those if you still want your light and dark themes:
|
||||||
|
|
||||||
|
```js
|
||||||
|
<ThemeProvider themes={['pink', 'red', 'blue', 'light', 'dark']}>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Without CSS variables
|
||||||
|
|
||||||
|
This library does not rely on your theme styling using CSS variables. You can hard-code the values in your CSS, and everything will work as expected (without any flashing):
|
||||||
|
|
||||||
|
```css
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
color: #000;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'],
|
||||||
|
[data-theme='dark'] body {
|
||||||
|
color: #fff;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Styled Components and any CSS-in-JS
|
||||||
|
|
||||||
|
Next Themes is completely CSS independent, it will work with any library. For example, with Styled Components you just need to `createGlobalStyle` in your custom App:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// pages/_app.js
|
||||||
|
import { createGlobalStyle } from 'styled-components'
|
||||||
|
import { ThemeProvider } from 'next-themes'
|
||||||
|
|
||||||
|
// Your themeing variables
|
||||||
|
const GlobalStyle = createGlobalStyle`
|
||||||
|
:root {
|
||||||
|
--fg: #000;
|
||||||
|
--bg: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--fg: #fff;
|
||||||
|
--bg: #000;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
function MyApp({ Component, pageProps }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GlobalStyle />
|
||||||
|
<ThemeProvider>
|
||||||
|
<Component {...pageProps} />
|
||||||
|
</ThemeProvider>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Avoid Hydration Mismatch
|
||||||
|
|
||||||
|
Because we cannot know the `theme` on the server, many of the values returned from `useTheme` will be `undefined` until mounted on the client. This means if you try to render UI based on the current theme before mounting on the client, you will see a hydration mismatch error.
|
||||||
|
|
||||||
|
The following code sample is **unsafe**:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
|
|
||||||
|
// Do NOT use this! It will throw a hydration mismatch error.
|
||||||
|
const ThemeSwitch = () => {
|
||||||
|
const { resolvedTheme, setTheme } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<select value={resolvedTheme} onChange={e => setTheme(e.target.value)}>
|
||||||
|
<option value="system">System</option>
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
<option value="light">Light</option>
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ThemeSwitch
|
||||||
|
```
|
||||||
|
|
||||||
|
To fix this, make sure you only render UI that uses the current theme when the page is mounted on the client:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
|
|
||||||
|
const ThemeSwitch = () => {
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
const { resolvedTheme, setTheme } = useTheme()
|
||||||
|
|
||||||
|
// useEffect only runs on the client, so now we can safely show the UI
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<select value={resolvedTheme} onChange={e => setTheme(e.target.value)}>
|
||||||
|
<option value="system">System</option>
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
<option value="light">Light</option>
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ThemeSwitch
|
||||||
|
```
|
||||||
|
|
||||||
|
To avoid [Layout Shift](https://web.dev/cls/), consider rendering a skeleton/placeholder until mounted on the client side.
|
||||||
|
|
||||||
|
#### Images
|
||||||
|
|
||||||
|
Showing different images based on the current theme also suffers from the hydration mismatch problem. With [`next/image`](https://nextjs.org/docs/basic-features/image-optimization) you can use an empty image until the theme is resolved:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
|
|
||||||
|
function ThemedImage() {
|
||||||
|
const { resolvedTheme } = useTheme()
|
||||||
|
let src
|
||||||
|
|
||||||
|
switch (resolvedTheme) {
|
||||||
|
case 'light':
|
||||||
|
src = '/light.png'
|
||||||
|
break
|
||||||
|
case 'dark':
|
||||||
|
src = '/dark.png'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Image src={src} width={400} height={400} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ThemedImage
|
||||||
|
```
|
||||||
|
|
||||||
|
#### CSS
|
||||||
|
|
||||||
|
You can also use CSS to hide or show content based on the current theme. To avoid the hydration mismatch, you'll need to render _both_ versions of the UI, with CSS hiding the unused version. For example:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
function ThemedImage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* When the theme is dark, hide this div */}
|
||||||
|
<div data-hide-on-theme="dark">
|
||||||
|
<Image src="light.png" width={400} height={400} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* When the theme is light, hide this div */}
|
||||||
|
<div data-hide-on-theme="light">
|
||||||
|
<Image src="dark.png" width={400} height={400} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ThemedImage
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
[data-theme='dark'] [data-hide-on-theme='dark'],
|
||||||
|
[data-theme='light'] [data-hide-on-theme='light'] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Tailwind
|
||||||
|
|
||||||
|
[Visit the live example](https://next-themes-tailwind.vercel.app) • [View the example source code](https://github.com/pacocoursey/next-themes/tree/master/examples/tailwind)
|
||||||
|
|
||||||
|
> NOTE! Tailwind only supports dark mode in version >2.
|
||||||
|
|
||||||
|
In your `tailwind.config.js`, set the dark mode property to class:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// tailwind.config.js
|
||||||
|
module.exports = {
|
||||||
|
darkMode: 'class'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Set the attribute for your Theme Provider to class:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// pages/_app.js
|
||||||
|
<ThemeProvider attribute="class">
|
||||||
|
```
|
||||||
|
|
||||||
|
If you're using the `value` prop to specify different attribute values, make sure your dark theme explicitly uses the "dark" value, as required by Tailwind.
|
||||||
|
|
||||||
|
That's it! Now you can use dark-mode specific classes:
|
||||||
|
|
||||||
|
```js
|
||||||
|
<h1 className="text-black dark:text-white">
|
||||||
|
```
|
||||||
|
|
||||||
|
## Discussion
|
||||||
|
|
||||||
|
### The Flash
|
||||||
|
|
||||||
|
ThemeProvider automatically injects a script into `next/head` to update the `html` element with the correct attributes before the rest of your page loads. This means the page will not flash under any circumstances, including forced themes, system theme, multiple themes, and incognito. No `noflash.js` required.
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Why is my page still flashing?**
|
||||||
|
|
||||||
|
In Next.js dev mode, the page may still flash. When you build your app in production mode, there will be no flashing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Why do I get server/client mismatch error?**
|
||||||
|
|
||||||
|
When using `useTheme`, you will use see a hydration mismatch error when rendering UI that relies on the current theme. This is because many of the values returned by `useTheme` are undefined on the server, since we can't read `localStorage` until mounting on the client. See the [example](#avoid-hydration-mismatch) for how to fix this error.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Do I need to use CSS variables with this library?**
|
||||||
|
|
||||||
|
Nope. See the [example](#without-css-variables).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Can I set the class or data attribute on the body or another element?**
|
||||||
|
|
||||||
|
Nope. If you have a good reason for supporting this feature, please open an issue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Can I use this package with Gatsby or CRA?**
|
||||||
|
|
||||||
|
Nope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Is the injected script minified?**
|
||||||
|
|
||||||
|
Yes, using Terser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Why is `resolvedTheme` necessary?**
|
||||||
|
|
||||||
|
When supporting the System theme preference, you want to make sure that's reflected in your UI. This means your buttons, selects, dropdowns, or whatever you use to indicate the current theme should say "System" when the System theme preference is active.
|
||||||
|
|
||||||
|
If we didn't distinguish between `theme` and `resolvedTheme`, the UI would show "Dark" or "Light", when it should really be "System".
|
||||||
|
|
||||||
|
`resolvedTheme` is then useful for modifying behavior or styles at runtime:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { resolvedTheme } = useTheme()
|
||||||
|
|
||||||
|
<div style={{ color: resolvedTheme === 'dark' ? white : black }}>
|
||||||
|
```
|
||||||
|
|
||||||
|
If we didn't have `resolvedTheme` and only used `theme`, you'd lose information about the state of your UI (you would only know the theme is "system", and not what it resolved to).
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import type { UseThemeProps, ThemeProviderProps } from './types';
|
||||||
|
export declare const useTheme: () => UseThemeProps;
|
||||||
|
export declare const ThemeProvider: React.FC<ThemeProviderProps>;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
function e(e){if(e&&e.__esModule)return e;var t=Object.create(null);return e&&Object.keys(e).forEach(function(n){if("default"!==n){var r=Object.getOwnPropertyDescriptor(e,n);Object.defineProperty(t,n,r.get?r:{enumerable:!0,get:function(){return e[n]}})}}),t.default=e,t}var t=/*#__PURE__*/e(require("react"));const n=["light","dark"],r="(prefers-color-scheme: dark)",o="undefined"==typeof window,a=/*#__PURE__*/t.createContext(void 0),c={setTheme:e=>{},themes:[]},s=({forcedTheme:e,disableTransitionOnChange:o=!1,enableSystem:c=!0,enableColorScheme:s=!0,storageKey:d="theme",themes:h=["light","dark"],defaultTheme:f=(c?"system":"light"),attribute:v="data-theme",value:y,themeColor:g,children:b,nonce:$})=>{const[p,S]=t.useState(()=>e?"forced":i(d,f)),[w,C]=t.useState(()=>i(d)),E=y?Object.values(y):h,T=t.useRef(),k=t.useRef(),x=t.useRef(),O=t.useCallback(e=>{let t=e;if(!t)return;"system"===e&&c&&(t=m());const r=y?y[t]:t,a=o?u():null,l=document.documentElement;if("class"===v?(l.classList.remove(...E),r&&l.classList.add(r)):r?l.setAttribute(v,r):l.removeAttribute(v),g){let e=!1;const n=(i="string"==typeof g?g:g[t]).startsWith("var(--")?(k.current||(k.current=getComputedStyle(document.documentElement)),k.current.getPropertyValue(i.slice(4,-1))):i,r=(()=>{var t;if(null==(t=T.current)?void 0:t.isConnected)return T.current;const n=document.head.querySelector('meta[name="theme-color"]');if(n)return n;const r=document.createElement("meta");return r.setAttribute("name","theme-color"),e=!0,r})();T.current=r,n&&(r.removeAttribute("value"),r.setAttribute("content",n),e&&document.head.appendChild(r))}var i;if(s){const e=n.includes(f)?f:null,r=n.includes(t)?t:e;l.style.colorScheme=r}null==a||a()},[]),L=t.useCallback(t=>{if(S(t),!e)try{localStorage.setItem(d,t)}catch(e){}},[e]),j=t.useCallback(t=>{const n=m(t);C(n),"system"===p&&c&&!e&&O("system")},[p,e]);return t.useEffect(()=>{const e=window.matchMedia(r);return e.addListener(j),j(e),()=>e.removeListener(j)},[j]),t.useEffect(()=>{const t=t=>{t.key===d&&(e&&t.newValue?x.current=t.newValue:L(t.newValue||f))};return window.addEventListener("storage",t),()=>window.removeEventListener("storage",t)},[L]),t.useEffect(()=>{if(e&&"forced"!==p&&L("forced"),!e&&x.current)return L(x.current),O(x.current),void(x.current=void 0);O(null!=e?e:p)},[e,p]),/*#__PURE__*/t.createElement(a.Provider,{value:{theme:p,setTheme:L,resolvedTheme:(null!=e?e:"system"===p)?w:p,themes:c?[...h,"system"]:h,systemTheme:c?w:void 0}},/*#__PURE__*/t.createElement(l,{forcedTheme:e,disableTransitionOnChange:o,enableSystem:c,enableColorScheme:s,storageKey:d,themes:h,defaultTheme:f,attribute:v,value:y,children:b,attrs:E,nonce:$}),b)},l=/*#__PURE__*/t.memo(({forcedTheme:e,storageKey:o,attribute:a,enableSystem:c,enableColorScheme:s,defaultTheme:l,value:i,attrs:u,nonce:m})=>{const d="system"===l,h="class"===a?`var d=document.documentElement,c=d.classList;c.remove(${u.map(e=>`'${e}'`).join(",")});`:`var d=document.documentElement,n='${a}',s='setAttribute';`,f=s?n.includes(l)&&l?`if(e==='light'||e==='dark'||!e)d.style.colorScheme=e||'${l}'`:"if(e==='light'||e==='dark')d.style.colorScheme=e":"",v=(e,t=!1,r=!0)=>{const o=i?i[e]:e,c=t?e+"|| ''":`'${o}'`;let l="";return s&&r&&!t&&n.includes(e)&&(l+=`d.style.colorScheme = '${e}';`),"class"===a?l+=t||o?`c.add(${c})`:"null":o&&(l+=`d[s](n,${c})`),l},y=e?`!function(){${h}${v(e)}}()`:c?`!function(){try{${h}var e=localStorage.getItem('${o}');if('system'===e||(!e&&${d})){var t='${r}',m=window.matchMedia(t);if(m.media!==t||m.matches){${v("dark")}}else{${v("light")}}}else if(e){${i?`var x=${JSON.stringify(i)};`:""}${v(i?"x[e]":"e",!0)}}${d?"":"else{"+v(l,!1,!1)+"}"}${f}}catch(e){}}()`:`!function(){try{${h}var e=localStorage.getItem('${o}');if(e){${i?`var x=${JSON.stringify(i)};`:""}${v(i?"x[e]":"e",!0)}}else{${v(l,!1,!1)};}${f}}catch(t){}}();`;/*#__PURE__*/return t.createElement("script",{nonce:m,dangerouslySetInnerHTML:{__html:y}})},()=>!0),i=(e,t)=>{if(o)return;let n;try{n=localStorage.getItem(e)||void 0}catch(e){}return n||t},u=()=>{const e=document.createElement("style");return e.appendChild(document.createTextNode("*{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}")),document.head.appendChild(e),()=>{window.getComputedStyle(document.body),setTimeout(()=>{document.head.removeChild(e)},1)}},m=e=>(e||(e=window.matchMedia(r)),e.matches?"dark":"light");exports.ThemeProvider=e=>t.useContext(a)?/*#__PURE__*/t.createElement(t.Fragment,null,e.children):/*#__PURE__*/t.createElement(s,e),exports.useTheme=()=>{var e;return null!==(e=t.useContext(a))&&void 0!==e?e:c};
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
import*as e from"react";const t=["light","dark"],n="(prefers-color-scheme: dark)",r="undefined"==typeof window,o=/*#__PURE__*/e.createContext(void 0),a={setTheme:e=>{},themes:[]},s=()=>{var t;return null!==(t=e.useContext(o))&&void 0!==t?t:a},c=t=>e.useContext(o)?/*#__PURE__*/e.createElement(e.Fragment,null,t.children):/*#__PURE__*/e.createElement(l,t),l=({forcedTheme:r,disableTransitionOnChange:a=!1,enableSystem:s=!0,enableColorScheme:c=!0,storageKey:l="theme",themes:h=["light","dark"],defaultTheme:f=(s?"system":"light"),attribute:v="data-theme",value:y,themeColor:g,children:$,nonce:b})=>{const[S,p]=e.useState(()=>r?"forced":i(l,f)),[C,w]=e.useState(()=>i(l)),E=y?Object.values(y):h,T=e.useRef(),k=e.useRef(),x=e.useRef(),L=e.useCallback(e=>{let n=e;if(!n)return;"system"===e&&s&&(n=d());const r=y?y[n]:n,o=a?u():null,l=document.documentElement;if("class"===v?(l.classList.remove(...E),r&&l.classList.add(r)):r?l.setAttribute(v,r):l.removeAttribute(v),g){let e=!1;const t=(m="string"==typeof g?g:g[n]).startsWith("var(--")?(k.current||(k.current=getComputedStyle(document.documentElement)),k.current.getPropertyValue(m.slice(4,-1))):m,r=(()=>{var t;if(null==(t=T.current)?void 0:t.isConnected)return T.current;const n=document.head.querySelector('meta[name="theme-color"]');if(n)return n;const r=document.createElement("meta");return r.setAttribute("name","theme-color"),e=!0,r})();T.current=r,t&&(r.removeAttribute("value"),r.setAttribute("content",t),e&&document.head.appendChild(r))}var m;if(c){const e=t.includes(f)?f:null,r=t.includes(n)?n:e;l.style.colorScheme=r}null==o||o()},[]),A=e.useCallback(e=>{if(p(e),!r)try{localStorage.setItem(l,e)}catch(e){}},[r]),I=e.useCallback(e=>{const t=d(e);w(t),"system"===S&&s&&!r&&L("system")},[S,r]);return e.useEffect(()=>{const e=window.matchMedia(n);return e.addListener(I),I(e),()=>e.removeListener(I)},[I]),e.useEffect(()=>{const e=e=>{e.key===l&&(r&&e.newValue?x.current=e.newValue:A(e.newValue||f))};return window.addEventListener("storage",e),()=>window.removeEventListener("storage",e)},[A]),e.useEffect(()=>{if(r&&"forced"!==S&&A("forced"),!r&&x.current)return A(x.current),L(x.current),void(x.current=void 0);L(null!=r?r:S)},[r,S]),/*#__PURE__*/e.createElement(o.Provider,{value:{theme:S,setTheme:A,resolvedTheme:(null!=r?r:"system"===S)?C:S,themes:s?[...h,"system"]:h,systemTheme:s?C:void 0}},/*#__PURE__*/e.createElement(m,{forcedTheme:r,disableTransitionOnChange:a,enableSystem:s,enableColorScheme:c,storageKey:l,themes:h,defaultTheme:f,attribute:v,value:y,children:$,attrs:E,nonce:b}),$)},m=/*#__PURE__*/e.memo(({forcedTheme:r,storageKey:o,attribute:a,enableSystem:s,enableColorScheme:c,defaultTheme:l,value:m,attrs:i,nonce:u})=>{const d="system"===l,h="class"===a?`var d=document.documentElement,c=d.classList;c.remove(${i.map(e=>`'${e}'`).join(",")});`:`var d=document.documentElement,n='${a}',s='setAttribute';`,f=c?t.includes(l)&&l?`if(e==='light'||e==='dark'||!e)d.style.colorScheme=e||'${l}'`:"if(e==='light'||e==='dark')d.style.colorScheme=e":"",v=(e,n=!1,r=!0)=>{const o=m?m[e]:e,s=n?e+"|| ''":`'${o}'`;let l="";return c&&r&&!n&&t.includes(e)&&(l+=`d.style.colorScheme = '${e}';`),"class"===a?l+=n||o?`c.add(${s})`:"null":o&&(l+=`d[s](n,${s})`),l},y=r?`!function(){${h}${v(r)}}()`:s?`!function(){try{${h}var e=localStorage.getItem('${o}');if('system'===e||(!e&&${d})){var t='${n}',m=window.matchMedia(t);if(m.media!==t||m.matches){${v("dark")}}else{${v("light")}}}else if(e){${m?`var x=${JSON.stringify(m)};`:""}${v(m?"x[e]":"e",!0)}}${d?"":"else{"+v(l,!1,!1)+"}"}${f}}catch(e){}}()`:`!function(){try{${h}var e=localStorage.getItem('${o}');if(e){${m?`var x=${JSON.stringify(m)};`:""}${v(m?"x[e]":"e",!0)}}else{${v(l,!1,!1)};}${f}}catch(t){}}();`;/*#__PURE__*/return e.createElement("script",{nonce:u,dangerouslySetInnerHTML:{__html:y}})},()=>!0),i=(e,t)=>{if(r)return;let n;try{n=localStorage.getItem(e)||void 0}catch(e){}return n||t},u=()=>{const e=document.createElement("style");return e.appendChild(document.createTextNode("*{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}")),document.head.appendChild(e),()=>{window.getComputedStyle(document.body),setTimeout(()=>{document.head.removeChild(e)},1)}},d=e=>(e||(e=window.matchMedia(n)),e.matches?"dark":"light");export{c as ThemeProvider,s as useTheme};
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
import*as e from"react";const t=["light","dark"],n="(prefers-color-scheme: dark)",r="undefined"==typeof window,o=/*#__PURE__*/e.createContext(void 0),a={setTheme:e=>{},themes:[]},s=()=>{var t;return null!==(t=e.useContext(o))&&void 0!==t?t:a},c=t=>e.useContext(o)?/*#__PURE__*/e.createElement(e.Fragment,null,t.children):/*#__PURE__*/e.createElement(l,t),l=({forcedTheme:r,disableTransitionOnChange:a=!1,enableSystem:s=!0,enableColorScheme:c=!0,storageKey:l="theme",themes:h=["light","dark"],defaultTheme:f=(s?"system":"light"),attribute:v="data-theme",value:y,themeColor:g,children:$,nonce:b})=>{const[S,p]=e.useState(()=>r?"forced":i(l,f)),[C,w]=e.useState(()=>i(l)),E=y?Object.values(y):h,T=e.useRef(),k=e.useRef(),x=e.useRef(),L=e.useCallback(e=>{let n=e;if(!n)return;"system"===e&&s&&(n=d());const r=y?y[n]:n,o=a?u():null,l=document.documentElement;if("class"===v?(l.classList.remove(...E),r&&l.classList.add(r)):r?l.setAttribute(v,r):l.removeAttribute(v),g){let e=!1;const t=(m="string"==typeof g?g:g[n]).startsWith("var(--")?(k.current||(k.current=getComputedStyle(document.documentElement)),k.current.getPropertyValue(m.slice(4,-1))):m,r=(()=>{var t;if(null==(t=T.current)?void 0:t.isConnected)return T.current;const n=document.head.querySelector('meta[name="theme-color"]');if(n)return n;const r=document.createElement("meta");return r.setAttribute("name","theme-color"),e=!0,r})();T.current=r,t&&(r.removeAttribute("value"),r.setAttribute("content",t),e&&document.head.appendChild(r))}var m;if(c){const e=t.includes(f)?f:null,r=t.includes(n)?n:e;l.style.colorScheme=r}null==o||o()},[]),A=e.useCallback(e=>{if(p(e),!r)try{localStorage.setItem(l,e)}catch(e){}},[r]),I=e.useCallback(e=>{const t=d(e);w(t),"system"===S&&s&&!r&&L("system")},[S,r]);return e.useEffect(()=>{const e=window.matchMedia(n);return e.addListener(I),I(e),()=>e.removeListener(I)},[I]),e.useEffect(()=>{const e=e=>{e.key===l&&(r&&e.newValue?x.current=e.newValue:A(e.newValue||f))};return window.addEventListener("storage",e),()=>window.removeEventListener("storage",e)},[A]),e.useEffect(()=>{if(r&&"forced"!==S&&A("forced"),!r&&x.current)return A(x.current),L(x.current),void(x.current=void 0);L(null!=r?r:S)},[r,S]),/*#__PURE__*/e.createElement(o.Provider,{value:{theme:S,setTheme:A,resolvedTheme:(null!=r?r:"system"===S)?C:S,themes:s?[...h,"system"]:h,systemTheme:s?C:void 0}},/*#__PURE__*/e.createElement(m,{forcedTheme:r,disableTransitionOnChange:a,enableSystem:s,enableColorScheme:c,storageKey:l,themes:h,defaultTheme:f,attribute:v,value:y,children:$,attrs:E,nonce:b}),$)},m=/*#__PURE__*/e.memo(({forcedTheme:r,storageKey:o,attribute:a,enableSystem:s,enableColorScheme:c,defaultTheme:l,value:m,attrs:i,nonce:u})=>{const d="system"===l,h="class"===a?`var d=document.documentElement,c=d.classList;c.remove(${i.map(e=>`'${e}'`).join(",")});`:`var d=document.documentElement,n='${a}',s='setAttribute';`,f=c?t.includes(l)&&l?`if(e==='light'||e==='dark'||!e)d.style.colorScheme=e||'${l}'`:"if(e==='light'||e==='dark')d.style.colorScheme=e":"",v=(e,n=!1,r=!0)=>{const o=m?m[e]:e,s=n?e+"|| ''":`'${o}'`;let l="";return c&&r&&!n&&t.includes(e)&&(l+=`d.style.colorScheme = '${e}';`),"class"===a?l+=n||o?`c.add(${s})`:"null":o&&(l+=`d[s](n,${s})`),l},y=r?`!function(){${h}${v(r)}}()`:s?`!function(){try{${h}var e=localStorage.getItem('${o}');if('system'===e||(!e&&${d})){var t='${n}',m=window.matchMedia(t);if(m.media!==t||m.matches){${v("dark")}}else{${v("light")}}}else if(e){${m?`var x=${JSON.stringify(m)};`:""}${v(m?"x[e]":"e",!0)}}${d?"":"else{"+v(l,!1,!1)+"}"}${f}}catch(e){}}()`:`!function(){try{${h}var e=localStorage.getItem('${o}');if(e){${m?`var x=${JSON.stringify(m)};`:""}${v(m?"x[e]":"e",!0)}}else{${v(l,!1,!1)};}${f}}catch(t){}}();`;/*#__PURE__*/return e.createElement("script",{nonce:u,dangerouslySetInnerHTML:{__html:y}})},()=>!0),i=(e,t)=>{if(r)return;let n;try{n=localStorage.getItem(e)||void 0}catch(e){}return n||t},u=()=>{const e=document.createElement("style");return e.appendChild(document.createTextNode("*{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}")),document.head.appendChild(e),()=>{window.getComputedStyle(document.body),setTimeout(()=>{document.head.removeChild(e)},1)}},d=e=>(e||(e=window.matchMedia(n)),e.matches?"dark":"light");export{c as ThemeProvider,s as useTheme};
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports,require("react")):"function"==typeof define&&define.amd?define(["exports","react"],t):t((e||self).nextThemes={},e.react)}(this,function(e,t){function n(e){if(e&&e.__esModule)return e;var t=Object.create(null);return e&&Object.keys(e).forEach(function(n){if("default"!==n){var r=Object.getOwnPropertyDescriptor(e,n);Object.defineProperty(t,n,r.get?r:{enumerable:!0,get:function(){return e[n]}})}}),t.default=e,t}var r=/*#__PURE__*/n(t);const o=["light","dark"],a="(prefers-color-scheme: dark)",c="undefined"==typeof window,s=/*#__PURE__*/r.createContext(void 0),l={setTheme:e=>{},themes:[]},i=({forcedTheme:e,disableTransitionOnChange:t=!1,enableSystem:n=!0,enableColorScheme:c=!0,storageKey:l="theme",themes:i=["light","dark"],defaultTheme:f=(n?"system":"light"),attribute:y="data-theme",value:v,themeColor:g,children:b,nonce:p})=>{const[$,S]=r.useState(()=>e?"forced":m(l,f)),[T,w]=r.useState(()=>m(l)),C=v?Object.values(v):i,E=r.useRef(),k=r.useRef(),x=r.useRef(),O=r.useCallback(e=>{let r=e;if(!r)return;"system"===e&&n&&(r=h());const a=v?v[r]:r,s=t?d():null,l=document.documentElement;if("class"===y?(l.classList.remove(...C),a&&l.classList.add(a)):a?l.setAttribute(y,a):l.removeAttribute(y),g){let e=!1;const t=(i="string"==typeof g?g:g[r]).startsWith("var(--")?(k.current||(k.current=getComputedStyle(document.documentElement)),k.current.getPropertyValue(i.slice(4,-1))):i,n=(()=>{var t;if(null==(t=E.current)?void 0:t.isConnected)return E.current;const n=document.head.querySelector('meta[name="theme-color"]');if(n)return n;const r=document.createElement("meta");return r.setAttribute("name","theme-color"),e=!0,r})();E.current=n,t&&(n.removeAttribute("value"),n.setAttribute("content",t),e&&document.head.appendChild(n))}var i;if(c){const e=o.includes(f)?f:null,t=o.includes(r)?r:e;l.style.colorScheme=t}null==s||s()},[]),L=r.useCallback(t=>{if(S(t),!e)try{localStorage.setItem(l,t)}catch(e){}},[e]),j=r.useCallback(t=>{const r=h(t);w(r),"system"===$&&n&&!e&&O("system")},[$,e]);return r.useEffect(()=>{const e=window.matchMedia(a);return e.addListener(j),j(e),()=>e.removeListener(j)},[j]),r.useEffect(()=>{const t=t=>{t.key===l&&(e&&t.newValue?x.current=t.newValue:L(t.newValue||f))};return window.addEventListener("storage",t),()=>window.removeEventListener("storage",t)},[L]),r.useEffect(()=>{if(e&&"forced"!==$&&L("forced"),!e&&x.current)return L(x.current),O(x.current),void(x.current=void 0);O(null!=e?e:$)},[e,$]),/*#__PURE__*/r.createElement(s.Provider,{value:{theme:$,setTheme:L,resolvedTheme:(null!=e?e:"system"===$)?T:$,themes:n?[...i,"system"]:i,systemTheme:n?T:void 0}},/*#__PURE__*/r.createElement(u,{forcedTheme:e,disableTransitionOnChange:t,enableSystem:n,enableColorScheme:c,storageKey:l,themes:i,defaultTheme:f,attribute:y,value:v,children:b,attrs:C,nonce:p}),b)},u=/*#__PURE__*/r.memo(({forcedTheme:e,storageKey:t,attribute:n,enableSystem:c,enableColorScheme:s,defaultTheme:l,value:i,attrs:u,nonce:m})=>{const d="system"===l,h="class"===n?`var d=document.documentElement,c=d.classList;c.remove(${u.map(e=>`'${e}'`).join(",")});`:`var d=document.documentElement,n='${n}',s='setAttribute';`,f=s?o.includes(l)&&l?`if(e==='light'||e==='dark'||!e)d.style.colorScheme=e||'${l}'`:"if(e==='light'||e==='dark')d.style.colorScheme=e":"",y=(e,t=!1,r=!0)=>{const a=i?i[e]:e,c=t?e+"|| ''":`'${a}'`;let l="";return s&&r&&!t&&o.includes(e)&&(l+=`d.style.colorScheme = '${e}';`),"class"===n?l+=t||a?`c.add(${c})`:"null":a&&(l+=`d[s](n,${c})`),l},v=e?`!function(){${h}${y(e)}}()`:c?`!function(){try{${h}var e=localStorage.getItem('${t}');if('system'===e||(!e&&${d})){var t='${a}',m=window.matchMedia(t);if(m.media!==t||m.matches){${y("dark")}}else{${y("light")}}}else if(e){${i?`var x=${JSON.stringify(i)};`:""}${y(i?"x[e]":"e",!0)}}${d?"":"else{"+y(l,!1,!1)+"}"}${f}}catch(e){}}()`:`!function(){try{${h}var e=localStorage.getItem('${t}');if(e){${i?`var x=${JSON.stringify(i)};`:""}${y(i?"x[e]":"e",!0)}}else{${y(l,!1,!1)};}${f}}catch(t){}}();`;/*#__PURE__*/return r.createElement("script",{nonce:m,dangerouslySetInnerHTML:{__html:v}})},()=>!0),m=(e,t)=>{if(c)return;let n;try{n=localStorage.getItem(e)||void 0}catch(e){}return n||t},d=()=>{const e=document.createElement("style");return e.appendChild(document.createTextNode("*{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}")),document.head.appendChild(e),()=>{window.getComputedStyle(document.body),setTimeout(()=>{document.head.removeChild(e)},1)}},h=e=>(e||(e=window.matchMedia(a)),e.matches?"dark":"light");e.ThemeProvider=e=>r.useContext(s)?/*#__PURE__*/r.createElement(r.Fragment,null,e.children):/*#__PURE__*/r.createElement(i,e),e.useTheme=()=>{var e;return null!==(e=r.useContext(s))&&void 0!==e?e:l}});
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
/// <reference types="react" />
|
||||||
|
interface ValueObject {
|
||||||
|
[themeName: string]: string;
|
||||||
|
}
|
||||||
|
export interface UseThemeProps {
|
||||||
|
/** List of all available theme names */
|
||||||
|
themes: string[];
|
||||||
|
/** Update the theme */
|
||||||
|
setTheme: (theme: string) => void;
|
||||||
|
/** Active theme name */
|
||||||
|
theme?: string;
|
||||||
|
/**
|
||||||
|
* If `enableSystem` is true and the active theme is "system", this returns whether the system preference resolved to "dark" or "light".
|
||||||
|
* If `forcedTheme` is set, the forced theme value is returned.
|
||||||
|
* Otherwise, identical to `theme`.
|
||||||
|
*/
|
||||||
|
resolvedTheme?: string;
|
||||||
|
/** If enableSystem is true, returns the System theme preference ("dark" or "light"), regardless what the active theme is */
|
||||||
|
systemTheme?: 'dark' | 'light';
|
||||||
|
}
|
||||||
|
export interface ThemeProviderProps {
|
||||||
|
/** List of all available theme names */
|
||||||
|
themes?: string[];
|
||||||
|
/** Forced theme name for the current page */
|
||||||
|
forcedTheme?: string;
|
||||||
|
/** Whether to switch between dark and light themes based on prefers-color-scheme */
|
||||||
|
enableSystem?: boolean;
|
||||||
|
/** Disable all CSS transitions when switching themes */
|
||||||
|
disableTransitionOnChange?: boolean;
|
||||||
|
/** Whether to indicate to browsers which color scheme is used (dark or light) for built-in UI like inputs and buttons */
|
||||||
|
enableColorScheme?: boolean;
|
||||||
|
/** Key used to store theme setting in localStorage */
|
||||||
|
storageKey?: string;
|
||||||
|
/** Default theme name (for v0.0.12 and lower the default was light). If `enableSystem` is false, the default theme is light */
|
||||||
|
defaultTheme?: string;
|
||||||
|
/** HTML attribute modified based on the active theme. Accepts `class` and `data-*` (meaning any data attribute, `data-mode`, `data-color`, etc.) */
|
||||||
|
attribute?: string | 'class';
|
||||||
|
/** Mapping of theme name to HTML attribute value. Object where key is the theme name and value is the attribute value */
|
||||||
|
value?: ValueObject;
|
||||||
|
/** Mapping of theme name to theme-color meta tag. CSS color string, or object where key is the theme name and value is the meta tag value */
|
||||||
|
themeColor?: string | ValueObject;
|
||||||
|
/** Nonce string to pass to the inline script for CSP headers */
|
||||||
|
nonce?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
export {};
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
{
|
||||||
|
"name": "next-themes",
|
||||||
|
"version": "1.0.0-beta.0",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"module": "./dist/index.module.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"source": "./src/index.tsx",
|
||||||
|
"license": "MIT",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"prepublish": "yarn build",
|
||||||
|
"build": "microbundle --jsx React.createElement --compress --no-sourcemap",
|
||||||
|
"test": "jest __tests__",
|
||||||
|
"test:e2e": "yarn playwright test"
|
||||||
|
},
|
||||||
|
"dependencies": {},
|
||||||
|
"peerDependencies": {
|
||||||
|
"next": "*",
|
||||||
|
"react": "*",
|
||||||
|
"react-dom": "*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.13.10",
|
||||||
|
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.14.5",
|
||||||
|
"@babel/plugin-proposal-optional-chaining": "^7.11.0",
|
||||||
|
"@babel/preset-env": "^7.13.10",
|
||||||
|
"@babel/preset-react": "^7.12.13",
|
||||||
|
"@babel/preset-typescript": "^7.13.0",
|
||||||
|
"@playwright/test": "^1.21.1",
|
||||||
|
"@testing-library/react": "^12.1.5",
|
||||||
|
"@types/jest": "^26.0.21",
|
||||||
|
"@types/next": "^9.0.0",
|
||||||
|
"@types/react": "^16.9.53",
|
||||||
|
"babel-jest": "^26.6.3",
|
||||||
|
"jest": "^27.5.1",
|
||||||
|
"microbundle": "^0.15.0",
|
||||||
|
"prettier": "^2.2.1",
|
||||||
|
"react": "^17.0.1",
|
||||||
|
"react-dom": "^17.0.1",
|
||||||
|
"typescript": "^4.0.3"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/pacocoursey/next-themes.git"
|
||||||
|
},
|
||||||
|
"prettier": {
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
/* Reproduce the user's problem: full console, error overlay, and clicking the Theme toggle. */
|
||||||
|
const { spawn } = require("node:child_process");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const http = require("node:http");
|
||||||
|
const path = require("node:path");
|
||||||
|
const EDGE = "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe";
|
||||||
|
const PORT = 9350;
|
||||||
|
const BASE = "http://localhost:3210";
|
||||||
|
const OUT = path.join(__dirname, "..", "shots");
|
||||||
|
function httpJson(p) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request({ host: "127.0.0.1", port: PORT, path: p }, (res) => {
|
||||||
|
let d = ""; res.on("data", (c) => (d += c));
|
||||||
|
res.on("end", () => { try { resolve(JSON.parse(d)); } catch { resolve(d); } });
|
||||||
|
});
|
||||||
|
req.on("error", reject); req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
(async () => {
|
||||||
|
const profile = fs.mkdtempSync("/tmp/edge-prof-");
|
||||||
|
const proc = spawn(EDGE, ["--headless=new", `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "--disable-gpu", "about:blank"], { stdio: "ignore" });
|
||||||
|
let targets = null;
|
||||||
|
for (let i = 0; i < 40; i++) { await new Promise((r) => setTimeout(r, 500)); try { targets = await httpJson("/json/list"); break; } catch {} }
|
||||||
|
if (!targets) { console.error("no targets"); process.exit(1); }
|
||||||
|
const page = targets.find((t) => t.type === "page");
|
||||||
|
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||||
|
await new Promise((r) => (ws.onopen = r));
|
||||||
|
let id = 0;
|
||||||
|
const send = (method, params = {}) => new Promise((resolve) => {
|
||||||
|
const myId = ++id; ws.send(JSON.stringify({ id: myId, method, params }));
|
||||||
|
const t = setInterval(() => { const m = ws._buf.find((b) => b.id === myId); if (m) { clearInterval(t); resolve(m.result ?? m.error); } }, 10);
|
||||||
|
});
|
||||||
|
ws._buf = [];
|
||||||
|
const full = (a) => (a.value ?? a.description ?? a.unserializableValue ?? a.type ?? "").toString();
|
||||||
|
ws.onmessage = (m) => {
|
||||||
|
const msg = JSON.parse(m.data); if (msg.id) { ws._buf.push(msg); return; }
|
||||||
|
if (msg.method === "Runtime.consoleAPICalled") console.log(`[console:${msg.params.type}]`, msg.params.args.map(full).join(" "));
|
||||||
|
if (msg.method === "Runtime.exceptionThrown") console.log("[EXCEPTION]", JSON.stringify(msg.params.exceptionDetails.exception?.description ?? msg.params.exceptionDetails.text));
|
||||||
|
if (msg.method === "Log.entryAdded" && msg.params.entry.level === "error") console.log("[log:error]", msg.params.entry.text);
|
||||||
|
};
|
||||||
|
|
||||||
|
await send("Runtime.enable"); await send("Page.enable"); await send("Network.enable");
|
||||||
|
const cookieFile = process.argv[2];
|
||||||
|
const cookieValue = cookieFile ? (fs.readFileSync(cookieFile, "utf8").split("\n").map((l) => l.trim()).find((l) => l.includes("authjs.session-token")) || "").split("\t").pop() : null;
|
||||||
|
if (cookieValue) await send("Network.setCookies", { cookies: [{ name: "authjs.session-token", value: cookieValue, domain: "localhost", path: "/", httpOnly: true, sameSite: "Lax" }] });
|
||||||
|
|
||||||
|
await send("Emulation.setDeviceMetricsOverride", { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false });
|
||||||
|
await send("Page.navigate", { url: BASE + "/" });
|
||||||
|
await new Promise((r) => setTimeout(r, 4500));
|
||||||
|
|
||||||
|
const state = await send("Runtime.evaluate", { expression: `(() => ({
|
||||||
|
title: document.title,
|
||||||
|
bodyLen: document.body.innerHTML.length,
|
||||||
|
hasNextErrorOverlay: !!document.querySelector('nextjs-portal, [data-nextjs-dialog], iframe[title="error"]'),
|
||||||
|
themeBtn: !!Array.from(document.querySelectorAll('button')).find(b => /theme/i.test(b.textContent||'') || b.getAttribute('aria-label') && /theme/i.test(b.getAttribute('aria-label'))),
|
||||||
|
}))()`, returnByValue: true });
|
||||||
|
console.log("STATE", JSON.stringify(state.result.value));
|
||||||
|
|
||||||
|
// Find the theme trigger in the sidebar and click it.
|
||||||
|
const clickRes = await send("Runtime.evaluate", { expression: `(() => {
|
||||||
|
const btns = Array.from(document.querySelectorAll('button'));
|
||||||
|
const t = btns.find(b => /theme/i.test(b.textContent||'') || (b.getAttribute('aria-label')||'').match(/theme/i));
|
||||||
|
if (!t) return 'no-theme-button';
|
||||||
|
const r = t.getBoundingClientRect();
|
||||||
|
return JSON.stringify({ label: (t.textContent||t.getAttribute('aria-label')).trim(), x: r.x + r.width/2, y: r.y + r.height/2 });
|
||||||
|
})()`, returnByValue: true });
|
||||||
|
console.log("THEME BTN", clickRes.result?.value);
|
||||||
|
|
||||||
|
if (clickRes.result?.value && clickRes.result.value !== "no-theme-button") {
|
||||||
|
const p = JSON.parse(clickRes.result.value);
|
||||||
|
await send("Input.dispatchMouseEvent", { type: "mousePressed", x: p.x, y: p.y, button: "left", clickCount: 1 });
|
||||||
|
await send("Input.dispatchMouseEvent", { type: "mouseReleased", x: p.x, y: p.y, button: "left", clickCount: 1 });
|
||||||
|
await new Promise((r) => setTimeout(r, 800));
|
||||||
|
const menu = await send("Runtime.evaluate", { expression: `(() => {
|
||||||
|
const opts = Array.from(document.querySelectorAll('button, [role="radio"], [role="option"]')).map(e => (e.textContent||'').trim()).filter(t => /^(Default|Sunset|Dark|Ocean|System)$/i.test(t));
|
||||||
|
return JSON.stringify(opts);
|
||||||
|
})()`, returnByValue: true });
|
||||||
|
|
||||||
|
console.log("MENU OPTIONS", menu.result?.value);
|
||||||
|
// click the "Ocean" option and verify the theme actually switches
|
||||||
|
const oceanPos = await send("Runtime.evaluate", { expression: `(() => {
|
||||||
|
const b = Array.from(document.querySelectorAll("button")).find(x => (x.textContent||"").trim() === "Ocean");
|
||||||
|
if (!b) return null; const r = b.getBoundingClientRect(); return JSON.stringify({ x: r.x + 10, y: r.y + r.height/2 });
|
||||||
|
})()`, returnByValue: true });
|
||||||
|
if (oceanPos.result?.value) {
|
||||||
|
const pos = JSON.parse(oceanPos.result.value);
|
||||||
|
await send("Input.dispatchMouseEvent", { type: "mousePressed", x: pos.x, y: pos.y, button: "left", clickCount: 1 });
|
||||||
|
await send("Input.dispatchMouseEvent", { type: "mouseReleased", x: pos.x, y: pos.y, button: "left", clickCount: 1 });
|
||||||
|
await new Promise((r) => setTimeout(r, 1200));
|
||||||
|
const after = await send("Runtime.evaluate", { expression: `JSON.stringify({
|
||||||
|
html: document.documentElement.className.split(" ").filter(Boolean).slice(-1),
|
||||||
|
cardBg: (() => { const c = document.querySelector("[aria-label^=\"Drag to move\"]")?.closest("[class*=\\\"rounded-2xl\\\"]"); return c ? c.getAttribute("style")?.match(/background-color: ([^;]+)/)?.[1] : null; })(),
|
||||||
|
ls: localStorage.getItem("theme"),
|
||||||
|
})`, returnByValue: true });
|
||||||
|
console.log("AFTER-CLICK", after.result?.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
proc.kill(); process.exit(0);
|
||||||
|
})().catch((e) => { console.error("FAIL", e.message); process.exit(1); });
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
/* Verify every theme switch updates group card colors in the UI (no reload). */
|
||||||
|
const { spawn } = require("node:child_process");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const http = require("node:http");
|
||||||
|
const EDGE = "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe";
|
||||||
|
const PORT = 9357;
|
||||||
|
function httpJson(p){return new Promise((res,rej)=>{const r=http.request({host:"127.0.0.1",port:PORT,path:p},(x)=>{let d="";x.on("data",c=>d+=c);x.on("end",()=>{try{res(JSON.parse(d))}catch{res(d)}})});r.on("error",rej);r.end()})}
|
||||||
|
(async()=>{
|
||||||
|
const prof=fs.mkdtempSync(require("node:os").tmpdir()+"/edge-prof-");
|
||||||
|
const proc=spawn(EDGE,["--headless=new","--remote-debugging-port="+PORT,"--user-data-dir="+prof,"--no-first-run","--no-default-browser-check","--disable-gpu","about:blank"],{stdio:"ignore"});
|
||||||
|
let tg=null;for(let i=0;i<40;i++){await new Promise(r=>setTimeout(r,500));try{tg=await httpJson("/json/list");break}catch{}}
|
||||||
|
const page=tg.find(t=>t.type==="page");
|
||||||
|
const ws=new WebSocket(page.webSocketDebuggerUrl);
|
||||||
|
await new Promise(r=>ws.onopen=r);let id=0;
|
||||||
|
const send=(m,p={})=>new Promise(res=>{const i2=++id;ws.send(JSON.stringify({id:i2,method:m,params:p}));const t=setInterval(()=>{const f=ws._buf.find(b=>b.id===i2);if(f){clearInterval(t);res(f.result??f.error)}},10)});
|
||||||
|
ws._buf=[];const errs=[];
|
||||||
|
ws.onmessage=(m)=>{const j=JSON.parse(m.data);if(j.id){ws._buf.push(j);return;}
|
||||||
|
if(j.method==="Runtime.consoleAPICalled"&&j.params.type==="error"){errs.push(j.params.args.map(a=>(a.value??a.description??"")).join(" ").slice(0,150));}
|
||||||
|
if(j.method==="Runtime.exceptionThrown"){errs.push("EXC: "+(j.params.exceptionDetails?.exception?.description||"").slice(0,120));}};
|
||||||
|
await send("Runtime.enable");await send("Page.enable");await send("Network.enable");
|
||||||
|
const cv=fs.readFileSync(process.argv[1],"utf8").split("\n").map(l=>l.trim()).find(l=>l.includes("authjs.session-token")).split("\t").pop();
|
||||||
|
await send("Network.setCookies",{cookies:[{name:"authjs.session-token",value:cv,domain:"localhost",path:"/",httpOnly:true,sameSite:"Lax"}]});
|
||||||
|
await send("Emulation.setDeviceMetricsOverride",{width:1440,height:900,deviceScaleFactor:1});
|
||||||
|
await send("Page.navigate",{url:"http://localhost:3210/"});
|
||||||
|
await new Promise(r=>setTimeout(r,5000));
|
||||||
|
// wait until group cards (divs with inline borderColor+backgroundColor) have rendered
|
||||||
|
const cardQ=`Array.from(document.querySelectorAll("div")).some(d=>{const st=d.getAttribute("style")||"";return /background-color/.test(st)&&/border-color/.test(st)})`;
|
||||||
|
for(let i=0;i<30;i++){ if(await send("Runtime.evaluate",{expression:cardQ,returnByValue:true}).then(r=>r.result?.value)){ break; } await new Promise(r=>setTimeout(r,1000)); }
|
||||||
|
await new Promise(r=>setTimeout(r,500));
|
||||||
|
|
||||||
|
const ev=(expr)=>send("Runtime.evaluate",{expression:expr,returnByValue:true,awaitPromise:true}).then(r=>r.result?.value ?? r.exceptionDetails?.exception?.description);
|
||||||
|
const click=async(x,y)=>{await send("Input.dispatchMouseEvent",{type:"mousePressed",x,y,button:"left",clickCount:1});await send("Input.dispatchMouseEvent",{type:"mouseReleased",x,y,button:"left",clickCount:1});};
|
||||||
|
// Option button scoped to the open dropdown (avoids the "Default" *project* nav item)
|
||||||
|
const optionAt=async(name)=>{const v=await ev(`(()=>{
|
||||||
|
const menu=document.querySelector('[data-slot="dropdown-menu-content"]');
|
||||||
|
if(!menu) return null;
|
||||||
|
const b=Array.from(menu.querySelectorAll("button")).find(x=>(x.textContent||"").trim()==="${name}");
|
||||||
|
if(!b) return null; const r=b.getBoundingClientRect();
|
||||||
|
return JSON.stringify({x:Math.round(r.x+12),y:Math.round(r.y+r.height/2)});
|
||||||
|
})()`); return v?JSON.parse(v):null;};
|
||||||
|
const openMenu=async()=>{const v=await ev(`(()=>{const b=Array.from(document.querySelectorAll("button")).find(x=>(x.textContent||"").trim()==="Theme");if(!b)return null;const r=b.getBoundingClientRect();return JSON.stringify({x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)})})()`);if(!v)return false;const p=JSON.parse(v);await click(p.x,p.y);await new Promise(r=>setTimeout(r,500));return true;};
|
||||||
|
const state=async(label)=>{const out=JSON.stringify(await ev(`(()=>{
|
||||||
|
const cards=Array.from(document.querySelectorAll("[class*=rounded-2xl]")).filter(c=>/background-color/.test(c.getAttribute("style")||"")&&/border-color/.test(c.getAttribute("style")||""));
|
||||||
|
const card=cards[0]||{getAttribute:()=>""};
|
||||||
|
const s=card.getAttribute("style")||"";
|
||||||
|
return {
|
||||||
|
html:document.documentElement.className.split(" ").filter(Boolean).slice(-1).join(","),
|
||||||
|
cards:cards.length,
|
||||||
|
cardBg:(s.match(/background-color:([^;]+)/)||[])[1],
|
||||||
|
cardBorder:(s.match(/border-color:([^;]+)/)||[])[1],
|
||||||
|
bodyBg:getComputedStyle(document.body).backgroundColor
|
||||||
|
};
|
||||||
|
})()`));console.log(label,out);return out;};
|
||||||
|
|
||||||
|
const pickCard=await ev(`Array.from(document.querySelectorAll("div")).filter(d=>{const st=d.getAttribute("style")||"";return /background-color/.test(st)&&/border-color/.test(st)}).length`);
|
||||||
|
console.log(" cards-with-style:", pickCard);
|
||||||
|
let s1 = await state("1) initial (dark) ");
|
||||||
|
await openMenu(); let o = await optionAt("Default"); console.log(" -> clicking Default at", JSON.stringify(o));
|
||||||
|
if(o){await click(o.x,o.y);} await new Promise(r=>setTimeout(r,900));
|
||||||
|
let s2 = await state("2) after Default ");
|
||||||
|
await openMenu(); o = await optionAt("Sunset"); console.log(" -> clicking Sunset at", JSON.stringify(o));
|
||||||
|
if(o){await click(o.x,o.y);} await new Promise(r=>setTimeout(r,900));
|
||||||
|
let s3 = await state("3) after Sunset ");
|
||||||
|
await openMenu(); o = await optionAt("Ocean"); console.log(" -> clicking Ocean at", JSON.stringify(o));
|
||||||
|
if(o){await click(o.x,o.y);} await new Promise(r=>setTimeout(r,900));
|
||||||
|
let s4 = await state("4) after Ocean ");
|
||||||
|
await openMenu(); o = await optionAt("Dark"); console.log(" -> clicking Dark at", JSON.stringify(o));
|
||||||
|
if(o){await click(o.x,o.y);} await new Promise(r=>setTimeout(r,900));
|
||||||
|
let s5 = await state("5) back to Dark ");
|
||||||
|
|
||||||
|
const parse=(s)=>JSON.parse(s.slice(s.indexOf("{")));
|
||||||
|
const ok1 = parse(s2).cardBorder !== parse(s1).cardBorder || parse(s2).html !== parse(s1).html;
|
||||||
|
const ok2 = parse(s3).cardBg !== parse(s2).cardBg;
|
||||||
|
const ok3 = parse(s4).cardBg !== parse(s3).cardBg;
|
||||||
|
const ok4 = parse(s5).cardBg !== parse(s4).cardBg;
|
||||||
|
console.log("TRANSITIONS OK:", ok1, ok2, ok3, ok4, "| ALL:", ok1&&ok2&&ok3&&ok4);
|
||||||
|
const real=errs.filter(e=>!/favicon|manifest|icon|404/i.test(e));
|
||||||
|
console.log("CONSOLE ERRORS:", real.length); real.slice(0,5).forEach(e=>console.log(" -",e));
|
||||||
|
proc.kill();process.exit(0);
|
||||||
|
})().catch(e=>{console.error("FAIL",e.message);process.exit(1)});
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
/* Verify every theme switch updates group card colors in the UI (no reload).
|
||||||
|
* Robust menu clicks: verify the dropdown is open, retry, log what's in it. */
|
||||||
|
const { spawn } = require("node:child_process");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const http = require("node:http");
|
||||||
|
const EDGE = "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe";
|
||||||
|
const PORT = 9362;
|
||||||
|
function httpJson(p){return new Promise((res,rej)=>{const r=http.request({host:"127.0.0.1",port:PORT,path:p},(x)=>{let d="";x.on("data",c=>d+=c);x.on("end",()=>{try{res(JSON.parse(d))}catch{res(d)}})});r.on("error",rej);r.end()})}
|
||||||
|
(async()=>{
|
||||||
|
const prof=fs.mkdtempSync(require("node:os").tmpdir()+"/edge-prof-");
|
||||||
|
const proc=spawn(EDGE,["--headless=new","--remote-debugging-port="+PORT,"--user-data-dir="+prof,"--no-first-run","--no-default-browser-check","--disable-gpu","about:blank"],{stdio:"ignore"});
|
||||||
|
let tg=null;for(let i=0;i<40;i++){await new Promise(r=>setTimeout(r,500));try{tg=await httpJson("/json/list");break}catch{}}
|
||||||
|
const page=tg.find(t=>t.type==="page");
|
||||||
|
const ws=new WebSocket(page.webSocketDebuggerUrl);
|
||||||
|
await new Promise(r=>ws.onopen=r);let id=0;
|
||||||
|
const send=(m,p={})=>new Promise(res=>{const i2=++id;ws.send(JSON.stringify({id:i2,method:m,params:p}));const t=setInterval(()=>{const f=ws._buf.find(b=>b.id===i2);if(f){clearInterval(t);res(f.result??f.error)}},10)});
|
||||||
|
ws._buf=[];const errs=[];
|
||||||
|
ws.onmessage=(m)=>{const j=JSON.parse(m.data);if(j.id){ws._buf.push(j);return;}
|
||||||
|
if(j.method==="Runtime.consoleAPICalled"&&j.params.type==="error"){errs.push(j.params.args.map(a=>(a.value??a.description??"")).join(" ").slice(0,120));}
|
||||||
|
if(j.method==="Runtime.exceptionThrown"){errs.push("EXC: "+(j.params.exceptionDetails?.exception?.description||"").slice(0,120));}};
|
||||||
|
await send("Runtime.enable");await send("Page.enable");await send("Network.enable");
|
||||||
|
const cv=fs.readFileSync(process.argv[1],"utf8").split("\n").map(l=>l.trim()).find(l=>l.includes("authjs.session-token")).split("\t").pop();
|
||||||
|
await send("Network.setCookies",{cookies:[{name:"authjs.session-token",value:cv,domain:"localhost",path:"/",httpOnly:true,sameSite:"Lax"}]});
|
||||||
|
await send("Emulation.setDeviceMetricsOverride",{width:1440,height:900,deviceScaleFactor:1});
|
||||||
|
await send("Page.navigate",{url:"http://localhost:3210/"});
|
||||||
|
await new Promise(r=>setTimeout(r,10000));
|
||||||
|
|
||||||
|
const diag=async()=>{const t=await ev("document.title+\" | \"+document.body.innerText.length");const b=await ev("Array.from(document.querySelectorAll(\"button\")).length");console.log("PAGE",t,"buttons:",b);};
|
||||||
|
const ev=(expr)=>send("Runtime.evaluate",{expression:expr,returnByValue:true,awaitPromise:true}).then(r=>{try{return JSON.stringify(r.result?.value)}catch{return "ERR"}});
|
||||||
|
const click=async(x,y)=>{await send("Input.dispatchMouseEvent",{type:"mousePressed",x,y,button:"left",clickCount:1});await send("Input.dispatchMouseEvent",{type:"mouseReleased",x,y,button:"left",clickCount:1});};
|
||||||
|
const sleep=(ms)=>new Promise(r=>setTimeout(r,ms));
|
||||||
|
|
||||||
|
const cardState=async()=>JSON.parse(await ev(`(()=>{
|
||||||
|
const cs=Array.from(document.querySelectorAll("[class*=rounded-2xl]")).filter(c=>/background-color/.test(c.getAttribute("style")||""));
|
||||||
|
if(!cs.length) return {cards:0};
|
||||||
|
const s=cs[0].getAttribute("style");
|
||||||
|
return {cards:cs.length, bg:(s.match(/background-color:([^;]+)/)||[])[1], border:(s.match(/border-color:([^;]+)/)||[])[1], html:document.documentElement.className.split(" ").filter(Boolean).slice(-1).join(",")};
|
||||||
|
})()`)||"null");
|
||||||
|
|
||||||
|
const themeBtnPos=async()=>JSON.parse(await ev(`(()=>{const b=Array.from(document.querySelectorAll("button")).find(x=>(x.textContent||"").trim()==="Theme");if(!b)return null;const r=b.getBoundingClientRect();return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)}})()`)||"null");
|
||||||
|
const menuButtons=async()=>await ev(`(()=>{const m=document.querySelector('[data-slot="dropdown-menu-content"]');if(!m)return null;return Array.from(m.querySelectorAll("button")).map(b=>(b.textContent||"").trim())})()`)||"null";
|
||||||
|
|
||||||
|
async function selectTheme(name){
|
||||||
|
for(let attempt=1; attempt<=4; attempt++){
|
||||||
|
const tp=await themeBtnPos();
|
||||||
|
if(!tp){console.log(` ! Theme btn not found (attempt ${attempt})`);await sleep(500);continue;}
|
||||||
|
await click(tp.x,tp.y); await sleep(700);
|
||||||
|
let mb=await menuButtons();
|
||||||
|
if(!mb||mb==="null"){
|
||||||
|
console.log(` attempt ${attempt}: menu did not open`);
|
||||||
|
// toggle closed? open again
|
||||||
|
await sleep(300); continue;
|
||||||
|
}
|
||||||
|
const list=JSON.parse(mb);
|
||||||
|
const idx=list.findIndex(t=>t===name);
|
||||||
|
if(idx===-1){console.log(` attempt ${attempt}: menu open, options=${JSON.stringify(list)}, no ${name}`);await sleep(300);continue;}
|
||||||
|
const pos=JSON.parse(await ev(`(()=>{const m=document.querySelector('[data-slot="dropdown-menu-content"]');const b=Array.from(m.querySelectorAll("button"))[${idx}];const r=b.getBoundingClientRect();return {x:Math.round(r.x+12),y:Math.round(r.y+r.height/2)}})()`));
|
||||||
|
await click(pos.x,pos.y); await sleep(900);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await diag();
|
||||||
|
const seq=["Sunset","Default","Ocean","Dark","Sunset"];
|
||||||
|
const results={};
|
||||||
|
results["A"]=await cardState();
|
||||||
|
let letter=65;
|
||||||
|
for(const name of seq){
|
||||||
|
console.log(`selecting ${name}:`, await selectTheme(name)?"ok":"FAILED");
|
||||||
|
results[String.fromCharCode(++letter)]=await cardState();
|
||||||
|
}
|
||||||
|
for(const k of Object.keys(results)) console.log(k, results[k]);
|
||||||
|
const d=(a,b)=>a&&b&&a.cards>0&&a.bg!==b.bg;
|
||||||
|
console.log("FILL CHANGES: A->B:",d(results.A,results.B),"| B->C:",d(results.B,results.C),"| C->D:",d(results.C,results.D),"| D->E:",d(results.D,results.E),"| E->F:",d(results.E,results.F));
|
||||||
|
const real=errs.filter(e=>!/favicon|manifest|icon|404/i.test(e));
|
||||||
|
console.log("ERRORS:", real.length); real.slice(0,4).forEach(e=>console.log(" -",e));
|
||||||
|
proc.kill();process.exit(0);
|
||||||
|
})().catch(e=>{console.error("FAIL",e.message);process.exit(1)});
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
/* Verify: open the Theme menu and click "Ocean", confirm the app switches theme. */
|
||||||
|
const { spawn } = require("node:child_process");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const http = require("node:http");
|
||||||
|
const EDGE = "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe";
|
||||||
|
const PORT = 9351;
|
||||||
|
const BASE = "http://localhost:3210";
|
||||||
|
function httpJson(p) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request({ host: "127.0.0.1", port: PORT, path: p }, (res) => {
|
||||||
|
let d = ""; res.on("data", (c) => (d += c));
|
||||||
|
res.on("end", () => { try { resolve(JSON.parse(d)); } catch { resolve(d); } });
|
||||||
|
});
|
||||||
|
req.on("error", reject); req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
(async () => {
|
||||||
|
const profile = fs.mkdtempSync("/tmp/edge-prof-");
|
||||||
|
const proc = spawn(EDGE, ["--headless=new", `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "--disable-gpu", "about:blank"], { stdio: "ignore" });
|
||||||
|
let targets = null;
|
||||||
|
for (let i = 0; i < 40; i++) { await new Promise((r) => setTimeout(r, 500)); try { targets = await httpJson("/json/list"); break; } catch {} }
|
||||||
|
if (!targets) { console.error("no targets"); process.exit(1); }
|
||||||
|
const page = targets.find((t) => t.type === "page");
|
||||||
|
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||||
|
await new Promise((r) => (ws.onopen = r));
|
||||||
|
let id = 0;
|
||||||
|
const send = (method, params = {}) => new Promise((resolve) => {
|
||||||
|
const myId = ++id; ws.send(JSON.stringify({ id: myId, method, params }));
|
||||||
|
const t = setInterval(() => { const m = ws._buf.find((b) => b.id === myId); if (m) { clearInterval(t); resolve(m.result ?? m.error); } }, 10);
|
||||||
|
});
|
||||||
|
ws._buf = [];
|
||||||
|
let crashed = false;
|
||||||
|
ws.onmessage = (m) => { const msg = JSON.parse(m.data); if (msg.id) { ws._buf.push(msg); return; }
|
||||||
|
if (msg.method === "Runtime.consoleAPICalled" && msg.params.type === "error") { const t2 = msg.params.args.map(a=>a.value??a.description??a.type).join(" "); if (/Base UI|MenuGroup|hydrat/i.test(t2)) crashed = true; }
|
||||||
|
if (msg.method === "Runtime.exceptionThrown") crashed = true; };
|
||||||
|
await send("Runtime.enable"); await send("Page.enable"); await send("Network.enable");
|
||||||
|
const cookieFile = process.argv[2];
|
||||||
|
const cookieValue = cookieFile ? (fs.readFileSync(cookieFile, "utf8").split("\n").map((l) => l.trim()).find((l) => l.includes("authjs.session-token")) || "").split("\t").pop() : null;
|
||||||
|
if (cookieValue) await send("Network.setCookies", { cookies: [{ name: "authjs.session-token", value: cookieValue, domain: "localhost", path: "/", httpOnly: true, sameSite: "Lax" }] });
|
||||||
|
await send("Emulation.setDeviceMetricsOverride", { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false });
|
||||||
|
await send("Page.navigate", { url: BASE + "/" });
|
||||||
|
await new Promise((r) => setTimeout(r, 4500));
|
||||||
|
|
||||||
|
// Open the theme menu
|
||||||
|
const btn = (await send("Runtime.evaluate", { expression: `(() => { const b = Array.from(document.querySelectorAll("button")).find(x => (x.textContent||"").trim() === "Theme" || (x.getAttribute("aria-label")||"") === "Theme"); const r = b.getBoundingClientRect(); return JSON.stringify({ x: r.x + r.width / 2, y: r.y + r.height / 2 }); })()`, returnByValue: true })).result?.value;
|
||||||
|
const bp = JSON.parse(btn);
|
||||||
|
await send("Input.dispatchMouseEvent", { type: "mousePressed", x: bp.x, y: bp.y, button: "left", clickCount: 1 });
|
||||||
|
await send("Input.dispatchMouseEvent", { type: "mouseReleased", x: bp.x, y: bp.y, button: "left", clickCount: 1 });
|
||||||
|
await new Promise((r) => setTimeout(r, 700));
|
||||||
|
// Click "Ocean"
|
||||||
|
const oc = (await send("Runtime.evaluate", { expression: `(() => { const b = Array.from(document.querySelectorAll("button")).find(x => (x.textContent||"").trim() === "Ocean"); if(!b) return null; const r = b.getBoundingClientRect(); return JSON.stringify({ x: r.x + 12, y: r.y + r.height / 2 }); })()`, returnByValue: true })).result?.value;
|
||||||
|
if (oc) {
|
||||||
|
const op = JSON.parse(oc);
|
||||||
|
await send("Input.dispatchMouseEvent", { type: "mousePressed", x: op.x, y: op.y, button: "left", clickCount: 1 });
|
||||||
|
await send("Input.dispatchMouseEvent", { type: "mouseReleased", x: op.x, y: op.y, button: "left", clickCount: 1 });
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
const after = (await send("Runtime.evaluate", { expression: `JSON.stringify({ html: document.documentElement.className.split(" ").filter(Boolean).slice(-1), ls: localStorage.getItem("theme") })`, returnByValue: true })).result?.value;
|
||||||
|
console.log("OCEAN BTN FOUND:", !!oc, "| AFTER CLICK:", after, "| CRASHED (Base UI/hydration/exception):", crashed);
|
||||||
|
proc.kill(); process.exit(0);
|
||||||
|
})().catch((e) => { console.error("FAIL", e.message); process.exit(1); });
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { useTheme, resolveTheme } from "./theme-provider";
|
||||||
|
|
||||||
|
// The themes whose *surfaces* are dark (see app/globals.css): "dark" and
|
||||||
|
// "ocean". Anything that used to compare `resolvedTheme === "dark"` to pick
|
||||||
|
// a color variant (group card fills/strokes, markdown widget color modes,
|
||||||
|
// ...) uses this instead, so Ocean is treated exactly like Dark.
|
||||||
|
const DARK_SURFACES = new Set(["dark", "ocean"]);
|
||||||
|
|
||||||
|
function useMounted(): boolean {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
return mounted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the active theme has dark surfaces (Dark or Ocean).
|
||||||
|
*
|
||||||
|
* Reactive to UI theme switches: the value is derived from the provider's
|
||||||
|
* theme state (the single source of truth), so the moment `setTheme` fires,
|
||||||
|
* every consumer re-renders with the new variant in the same commit -- no
|
||||||
|
* stale card colors, no manual refresh needed.
|
||||||
|
*
|
||||||
|
* Hydration-safe: until mounted we always report "light", matching the
|
||||||
|
* server render (the no-FOUC script in app/layout.tsx covers the CSS side
|
||||||
|
* of that first frame).
|
||||||
|
*/
|
||||||
|
export function isDarkTheme(): boolean {
|
||||||
|
const mounted = useMounted();
|
||||||
|
const { theme } = useTheme();
|
||||||
|
if (!mounted) return false;
|
||||||
|
return DARK_SURFACES.has(resolveTheme(theme));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `data-color-mode` value the markdown editor/preview widgets expect.
|
||||||
|
* They only understand light/dark, so the light "sunset" look reports as
|
||||||
|
* light and the dark "ocean" look reports as dark.
|
||||||
|
*/
|
||||||
|
export function colorModeFromTheme(): "light" | "dark" {
|
||||||
|
return isDarkTheme() ? "dark" : "light";
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 34 KiB |