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
22 changes: 22 additions & 0 deletions apps/web/__tests__/unit/share-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
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("sr-only");
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;
}
19 changes: 11 additions & 8 deletions apps/web/app/s/[videoId]/Share.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import type { ShareDashboardDestination } from "@/lib/share-dashboard-destinatio
import { CaptionProvider } from "./_components/CaptionContext";
import { DashboardBackLink } from "./_components/DashboardBackLink";
import { PlaybackProvider } from "./_components/playback/PlaybackContext";
import { ShareNavigation } from "./_components/ShareNavigation";
import { ShareVideo } from "./_components/ShareVideo";
import { type ShareView, ShareViewToggle } from "./_components/ShareViewToggle";
import { Sidebar } from "./_components/Sidebar";
Expand Down Expand Up @@ -750,7 +751,7 @@ export const Share = ({
*/}
<div
className={clsx(
"shrink-0 border-b border-gray-5 bg-white px-4 lg:px-8",
"shrink-0 border-b border-gray-5 bg-gray-1 px-4 lg:px-8",
view === "timeline" && "hidden",
)}
>
Expand Down Expand Up @@ -790,13 +791,15 @@ export const Share = ({
// border), so the two read as one contained, rounded unit,
// centred at the same width. Classic view's toggle lives under
// the player instead.
<div className="mx-auto flex h-14 w-full max-w-6xl items-center justify-between gap-3 rounded-t-xl border border-b-0 border-gray-5 bg-white px-5">
<div className="mx-auto flex w-full max-w-6xl flex-col items-stretch gap-2 rounded-t-xl border border-b-0 border-gray-5 bg-gray-1 px-3 py-2 sm:h-14 sm:flex-row sm:items-center sm:justify-between sm:gap-3 sm:px-5 sm:py-0">
<div className="flex min-w-0 items-center gap-3">
{dashboardDestination && (
{dashboardDestination ? (
<DashboardBackLink
destination={dashboardDestination}
compact
className="min-h-11 shrink-0 max-w-[45%]"
/>
) : (
<ShareNavigation />
)}
<SignedImageUrl
image={data.owner.image}
Expand Down Expand Up @@ -871,7 +874,7 @@ export const Share = ({
// on its own, so no overflow-clip — the shelf's cards must
// escape.
view === "timeline"
? "mx-auto w-full max-w-6xl gap-0 rounded-b-xl border border-t-0 border-gray-5 bg-white"
? "mx-auto w-full max-w-6xl gap-0 rounded-b-xl border border-t-0 border-gray-5 bg-gray-1"
: "gap-4",
// One column in both views: the comments rail left the grid to
// become a full-height pane beside it.
Expand Down Expand Up @@ -929,7 +932,7 @@ export const Share = ({
// the deck below the fold; the video pillarboxes inside.
view === "timeline"
? "rounded-none bg-[hsl(224,71.4%,4.1%)] w-full max-h-[65svh] lg:min-h-[280px] lg:max-h-[calc(100vh-340px)]"
: "rounded-2xl border border-gray-5 bg-white aspect-video",
: "rounded-2xl border border-gray-5 bg-gray-1 aspect-video",
)}
style={
view === "timeline"
Expand Down Expand Up @@ -1043,7 +1046,7 @@ export const Share = ({
onClick={toggleRail}
aria-controls={sidebarId}
aria-expanded={false}
className="hidden absolute right-0 top-1/2 items-center justify-center gap-2 -translate-y-1/2 rounded-lg border border-gray-5 bg-white h-10 px-3 text-sm font-medium text-gray-10 shadow-sm transition-colors hover:text-gray-12 lg:flex"
className="hidden absolute right-0 top-1/2 items-center justify-center gap-2 -translate-y-1/2 rounded-lg border border-gray-5 bg-gray-1 h-10 px-3 text-sm font-medium text-gray-10 shadow-sm transition-colors hover:text-gray-12 lg:flex"
>
<ChevronGlyph direction="left" />
Show sidebar
Expand Down Expand Up @@ -1155,7 +1158,7 @@ export const Share = ({
<aside
id={sidebarId}
className={clsx(
"shrink-0 px-4 pb-8 lg:p-0 lg:h-full lg:border-l lg:border-gray-5 lg:bg-white lg:overflow-hidden",
"shrink-0 px-4 pb-8 lg:p-0 lg:h-full lg:border-l lg:border-gray-5 lg:bg-gray-1 lg:overflow-hidden",
reduceMotion
? undefined
: "lg:transition-[width] lg:duration-300 lg:ease-out",
Expand Down
Loading