Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"method": "CUA Chrome Guest, file fixture; exact outer header class from current source, real Tailwind config + local font; placeholders for owner/avatar/content. Not full app validation.",
"before": {
"320": {
"avatarRight": 125,
"helpX": 99.890625,
"overlapPx": 25.109375,
"titleWidth": 0
},
"393": {
"avatarRight": 125,
"helpX": 172.890625,
"overlapPx": 0,
"titleWidth": 23.890625
}
},
"after": {
"320": {
"headerHeight": 99,
"titleWidth": 133.71875,
"avatarY": [15, 47],
"controlsY": [61, 91],
"overlap": false
},
"393": {
"headerHeight": 99,
"titleWidth": 133.71875,
"avatarY": [15, 47],
"controlsY": [61, 91],
"overlap": false
},
"640": {
"headerHeight": 56,
"titleWidth": 133.71875,
"avatarRight": 173.828125,
"controlsX": 419.890625,
"overlap": false
},
"1280": {
"headerHeight": 56,
"titleWidth": 133.71875,
"avatarRight": 221.828125,
"controlsX": 899.796875,
"overlap": false
}
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 21 additions & 0 deletions apps/web/__tests__/unit/share-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { ShareNavigation } from "@/app/s/[videoId]/_components/ShareNavigation";

const { currentUser } = vi.hoisted(() => ({ currentUser: vi.fn() }));
vi.mock("@/app/Layout/AuthContext", () => ({ useCurrentUser: currentUser }));

describe("share navigation", () => {
it("links signed-in viewers directly to their library", () => {
currentUser.mockReturnValue({ id: "viewer" });
const markup = renderToStaticMarkup(createElement(ShareNavigation));
expect(markup).toContain('href="/dashboard/caps"');
expect(markup).toContain("My Caps");
expect(markup).not.toContain('target="_blank"');
});
it("does not expose account navigation to signed-out viewers", () => {
currentUser.mockReturnValue(null);
expect(renderToStaticMarkup(createElement(ShareNavigation))).toBe("");
});
});
172 changes: 172 additions & 0 deletions apps/web/__tests__/unit/share-theme-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import Cookies from "js-cookie";
import { JSDOM } from "jsdom";
import {
act,
type ComponentProps,
createElement,
Fragment,
useLayoutEffect,
} from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DashboardContexts } from "@/app/(org)/dashboard/Contexts";
import { ShareTheme } from "@/app/s/ShareTheme";
import { SonnerToaster } from "@/components/SonnerToastProvider";

vi.mock("@cap/env", () => ({ buildEnv: { NEXT_PUBLIC_IS_CAP: false } }));
vi.mock("next/navigation", () => ({
usePathname: () => window.location.pathname,
redirect: vi.fn(),
}));
vi.mock("@/app/Layout/AuthContext", () => ({
useCurrentUser: () => ({ id: "test-viewer" }),
}));
vi.mock(
"@/app/(org)/dashboard/settings/organization/components/InviteDialog",
() => ({ InviteDialog: () => null }),
);
vi.mock("@/components/UpgradeModal", () => ({ UpgradeModal: () => null }));

vi.mock("sonner", () => ({
Toaster: ({ theme }: { theme: string }) =>
createElement("div", { "data-toast-theme": theme }),
}));

let dom: JSDOM;
let root: Root;
let systemDark: boolean;
let mediaChanges: EventTarget;
const beforePaint: string[] = [];
const dashboardProps = {
children: null,
organizationData: null,
activeOrganization: null,
spacesData: null,
userCapsCount: 0,
organizationSettings: null,
userPreferences: null,
anyNewNotifications: false,
initialTheme: "light",
initialSidebarCollapsed: false,
referClicked: false,
shareableLinkUsage: null,
} satisfies ComponentProps<typeof DashboardContexts>;

function PaintProbe() {
useLayoutEffect(() => {
beforePaint.push(document.body.className);
});
return null;
}

async function navigate(route: "dashboard" | "share" | "marketing") {
window.history.replaceState(
null,
"",
route === "share" ? "/s/test-video" : `/${route}`,
);
const page =
route === "dashboard"
? createElement(DashboardContexts, dashboardProps)
: route === "share"
? createElement(ShareTheme)
: null;
await act(async () => {
root.render(
createElement(
Fragment,
null,
page,
createElement(PaintProbe),
createElement(SonnerToaster),
),
);
});
}

beforeEach(() => {
dom = new JSDOM(
"<!doctype html><body class='light'><div id='root'></div></body>",
{ url: "http://localhost" },
);
vi.stubGlobal("window", dom.window);
vi.stubGlobal("document", dom.window.document);
vi.stubGlobal("MutationObserver", dom.window.MutationObserver);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
systemDark = false;
mediaChanges = new EventTarget();
dom.window.matchMedia = vi.fn(() => ({
get matches() {
return systemDark;
},
media: "(prefers-color-scheme: dark)",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: mediaChanges.addEventListener.bind(mediaChanges),
removeEventListener: mediaChanges.removeEventListener.bind(mediaChanges),
dispatchEvent: mediaChanges.dispatchEvent.bind(mediaChanges),
}));
root = createRoot(document.getElementById("root") as HTMLElement);
beforePaint.length = 0;
});

afterEach(async () => {
await act(async () => root.unmount());
dom.window.close();
vi.unstubAllGlobals();
});

describe("theme at the navigation paint boundary", () => {
it("keeps saved dark mode before paint in both navigation directions", async () => {
Cookies.set("theme", "dark");
await navigate("dashboard");
await navigate("share");
await navigate("dashboard");
expect(beforePaint).toEqual(["dark", "dark", "dark"]);
expect(document.body.className).toBe("dark");
});

it("honors saved light mode even when the system is dark", async () => {
Cookies.set("theme", "light");
systemDark = true;
await navigate("dashboard");
await navigate("share");
await navigate("dashboard");
expect(beforePaint).toEqual(["light", "light", "light"]);
});

it("uses system dark on share pages and restores the dashboard default", async () => {
systemDark = true;
await navigate("dashboard");
await navigate("share");
await navigate("dashboard");
expect(beforePaint).toEqual(["light", "dark", "light"]);
});

it("keeps notifications aligned with system-dark share pages and navigation", async () => {
systemDark = true;
await navigate("share");
expect(
document
.querySelector("[data-toast-theme]")
?.getAttribute("data-toast-theme"),
).toBe("dark");
await navigate("dashboard");
expect(
document
.querySelector("[data-toast-theme]")
?.getAttribute("data-toast-theme"),
).toBe("light");
});

it("removes share listeners and dark mode when leaving for marketing", async () => {
systemDark = true;
await navigate("share");
await navigate("marketing");
mediaChanges.dispatchEvent(new Event("change"));
window.dispatchEvent(new dom.window.Event("focus"));
expect(beforePaint).toEqual(["dark", "light"]);
expect(document.body.className).toBe("light");
});
});
67 changes: 67 additions & 0 deletions apps/web/__tests__/unit/share-theme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
import { describe, expect, it } from "vitest";

const script = readFileSync("public/theme-script.js", "utf8");

function runTheme(
pathname: string,
cookie: string,
systemDark = false,
delayed = false,
) {
const classes = new Set(["light", "existing-class"]);
const body = {
classList: {
add: (value: string) => classes.add(value),
remove: (...values: string[]) =>
values.forEach((value) => {
classes.delete(value);
}),
},
};
const document = { cookie, body: delayed ? null : body };
let onReady = () => {};
runInNewContext(script, {
document,
window: {
location: { pathname },
matchMedia: () => ({ matches: systemDark }),
addEventListener: (_event: string, callback: () => void) => {
onReady = callback;
},
},
});
if (delayed) {
document.body = body;
onReady();
}
return [...classes];
}

describe("shared page theme initialization", () => {
it.each(["/s/video", "/s/video/edit", "/s"])(
"restores saved dark theme on %s",
(path) => {
expect(runTheme(path, "theme=dark")).toEqual(["existing-class", "dark"]);
},
);
it("honors explicit light over system dark", () => {
expect(runTheme("/s/video", "theme=light", true)).toContain("light");
});
it("uses system dark when no preference is saved", () => {
expect(runTheme("/s/video", "", true)).toContain("dark");
});
it("ignores unrelated theme cookie names", () => {
expect(
runTheme("/s/video", "other_theme=dark; theme=light", true),
).toContain("light");
});
it("waits for the body when loaded in the head", () => {
expect(runTheme("/s/video", "theme=dark", false, true)).toContain("dark");
});
it("preserves marketing pages and dashboard defaults", () => {
expect(runTheme("/pricing", "theme=dark", true)).toContain("light");
expect(runTheme("/dashboard/caps", "", true)).toContain("light");
});
});
4 changes: 2 additions & 2 deletions apps/web/app/(org)/dashboard/Contexts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { buildEnv } from "@cap/env";
import Cookies from "js-cookie";
import { redirect, usePathname } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { InviteDialog } from "@/app/(org)/dashboard/settings/organization/components/InviteDialog";
import { useCurrentUser } from "@/app/Layout/AuthContext";
import { UpgradeModal } from "@/components/UpgradeModal";
Expand Down Expand Up @@ -114,7 +114,7 @@ export function DashboardContexts({
},
[],
);
useEffect(() => {
useLayoutEffect(() => {
if (Cookies.get("theme")) {
document.body.className = Cookies.get("theme") as ITheme;
}
Expand Down
31 changes: 31 additions & 0 deletions apps/web/app/s/ShareTheme.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"use client";

import Cookies from "js-cookie";
import { useLayoutEffect } from "react";

export function ShareTheme() {
useLayoutEffect(() => {
const preference = window.matchMedia("(prefers-color-scheme: dark)");
const applyTheme = () => {
const savedTheme = Cookies.get("theme");
const theme =
savedTheme === "dark" || savedTheme === "light"
? savedTheme
: preference.matches
? "dark"
: "light";
document.body.classList.remove("light", "dark");
document.body.classList.add(theme);
};
applyTheme();
preference.addEventListener("change", applyTheme);
window.addEventListener("focus", applyTheme);
return () => {
preference.removeEventListener("change", applyTheme);
window.removeEventListener("focus", applyTheme);
document.body.classList.remove("dark");
document.body.classList.add("light");
};
}, []);
return null;
}
Loading