From 61c8fd0aa181f9a143eebc994caa30d26e75234c Mon Sep 17 00:00:00 2001 From: pavzagor Date: Wed, 7 Oct 2026 15:33:30 +0000 Subject: [PATCH] fix: restore share page navigation and dark theme --- .../__tests__/unit/share-navigation.test.ts | 22 +++ .../unit/share-theme-navigation.test.ts | 172 ++++++++++++++++++ apps/web/__tests__/unit/share-theme.test.ts | 67 +++++++ apps/web/app/(org)/dashboard/Contexts.tsx | 4 +- apps/web/app/s/ShareTheme.tsx | 31 ++++ apps/web/app/s/[videoId]/Share.tsx | 19 +- .../s/[videoId]/_components/ShareHeader.tsx | 15 +- .../[videoId]/_components/ShareNavigation.tsx | 19 ++ .../[videoId]/_components/ShareViewToggle.tsx | 2 +- .../app/s/[videoId]/_components/Sidebar.tsx | 2 +- .../[videoId]/_components/SummaryChapters.tsx | 4 +- .../app/s/[videoId]/_components/Toolbar.tsx | 2 +- .../s/[videoId]/_components/tabs/Settings.tsx | 2 +- .../s/[videoId]/_components/tabs/Summary.tsx | 6 +- .../_components/timeline/TimelineBranch.tsx | 2 +- .../timeline/TimelineBranchCluster.tsx | 2 +- .../timeline/TimelineHoverGhost.tsx | 2 +- .../timeline/TimelineMobileSheet.tsx | 2 +- .../_components/timeline/TimelineSkeleton.tsx | 2 +- .../_components/timeline/TimelineView.tsx | 2 +- .../recording/ScreenRecorderPanel.tsx | 2 +- .../timeline/recording/VoiceRecorderPanel.tsx | 2 +- .../_components/video/media-player.tsx | 6 +- .../app/s/[videoId]/edit/EditVideoClient.tsx | 2 +- apps/web/app/s/layout.tsx | 8 +- apps/web/components/SonnerToastProvider.tsx | 44 ++--- apps/web/public/theme-script.js | 20 +- 27 files changed, 390 insertions(+), 73 deletions(-) create mode 100644 apps/web/__tests__/unit/share-navigation.test.ts create mode 100644 apps/web/__tests__/unit/share-theme-navigation.test.ts create mode 100644 apps/web/__tests__/unit/share-theme.test.ts create mode 100644 apps/web/app/s/ShareTheme.tsx create mode 100644 apps/web/app/s/[videoId]/_components/ShareNavigation.tsx diff --git a/apps/web/__tests__/unit/share-navigation.test.ts b/apps/web/__tests__/unit/share-navigation.test.ts new file mode 100644 index 00000000000..e22bfb33fdc --- /dev/null +++ b/apps/web/__tests__/unit/share-navigation.test.ts @@ -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(""); + }); +}); diff --git a/apps/web/__tests__/unit/share-theme-navigation.test.ts b/apps/web/__tests__/unit/share-theme-navigation.test.ts new file mode 100644 index 00000000000..fa7032189a7 --- /dev/null +++ b/apps/web/__tests__/unit/share-theme-navigation.test.ts @@ -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; + +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( + "
", + { 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"); + }); +}); diff --git a/apps/web/__tests__/unit/share-theme.test.ts b/apps/web/__tests__/unit/share-theme.test.ts new file mode 100644 index 00000000000..94f1351fbad --- /dev/null +++ b/apps/web/__tests__/unit/share-theme.test.ts @@ -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"); + }); +}); diff --git a/apps/web/app/(org)/dashboard/Contexts.tsx b/apps/web/app/(org)/dashboard/Contexts.tsx index fe9cfc25f47..feae50f1a31 100644 --- a/apps/web/app/(org)/dashboard/Contexts.tsx +++ b/apps/web/app/(org)/dashboard/Contexts.tsx @@ -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"; @@ -114,7 +114,7 @@ export function DashboardContexts({ }, [], ); - useEffect(() => { + useLayoutEffect(() => { if (Cookies.get("theme")) { document.body.className = Cookies.get("theme") as ITheme; } diff --git a/apps/web/app/s/ShareTheme.tsx b/apps/web/app/s/ShareTheme.tsx new file mode 100644 index 00000000000..2ac4a739331 --- /dev/null +++ b/apps/web/app/s/ShareTheme.tsx @@ -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; +} diff --git a/apps/web/app/s/[videoId]/Share.tsx b/apps/web/app/s/[videoId]/Share.tsx index c747064d831..e1cefc48e32 100644 --- a/apps/web/app/s/[videoId]/Share.tsx +++ b/apps/web/app/s/[videoId]/Share.tsx @@ -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"; @@ -750,7 +751,7 @@ export const Share = ({ */}
@@ -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. -
+
- {dashboardDestination && ( + {dashboardDestination ? ( + ) : ( + )} Show sidebar @@ -1155,7 +1158,7 @@ export const Share = ({