diff --git a/.github/assets/share-navigation-theme/after-desktop.png b/.github/assets/share-navigation-theme/after-desktop.png new file mode 100644 index 00000000000..0a0e0855bbe Binary files /dev/null and b/.github/assets/share-navigation-theme/after-desktop.png differ diff --git a/.github/assets/share-navigation-theme/after-mobile.png b/.github/assets/share-navigation-theme/after-mobile.png new file mode 100644 index 00000000000..b91a3370089 Binary files /dev/null and b/.github/assets/share-navigation-theme/after-mobile.png differ diff --git a/.github/assets/share-navigation-theme/after-validation.mp4 b/.github/assets/share-navigation-theme/after-validation.mp4 new file mode 100644 index 00000000000..84df100cd13 Binary files /dev/null and b/.github/assets/share-navigation-theme/after-validation.mp4 differ diff --git a/.github/assets/share-navigation-theme/before-desktop.png b/.github/assets/share-navigation-theme/before-desktop.png new file mode 100644 index 00000000000..59c71bc118c Binary files /dev/null and b/.github/assets/share-navigation-theme/before-desktop.png differ diff --git a/.github/assets/share-navigation-theme/before-mobile.png b/.github/assets/share-navigation-theme/before-mobile.png new file mode 100644 index 00000000000..20519230539 Binary files /dev/null and b/.github/assets/share-navigation-theme/before-mobile.png differ diff --git a/.github/assets/share-navigation-theme/before-validation.mp4 b/.github/assets/share-navigation-theme/before-validation.mp4 new file mode 100644 index 00000000000..7273f1e5f4d Binary files /dev/null and b/.github/assets/share-navigation-theme/before-validation.mp4 differ diff --git a/.github/assets/share-navigation-theme/timeline-fixture-after.png b/.github/assets/share-navigation-theme/timeline-fixture-after.png new file mode 100644 index 00000000000..0093d0ae876 Binary files /dev/null and b/.github/assets/share-navigation-theme/timeline-fixture-after.png differ diff --git a/.github/assets/share-navigation-theme/timeline-fixture-before.png b/.github/assets/share-navigation-theme/timeline-fixture-before.png new file mode 100644 index 00000000000..fd962c84132 Binary files /dev/null and b/.github/assets/share-navigation-theme/timeline-fixture-before.png differ diff --git a/.github/assets/share-navigation-theme/timeline-fixture-measurements.json b/.github/assets/share-navigation-theme/timeline-fixture-measurements.json new file mode 100644 index 00000000000..4ab183f0174 --- /dev/null +++ b/.github/assets/share-navigation-theme/timeline-fixture-measurements.json @@ -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 + } + } +} diff --git a/.github/assets/share-navigation-theme/tooltip-fixture-before-after.png b/.github/assets/share-navigation-theme/tooltip-fixture-before-after.png new file mode 100644 index 00000000000..699be373da9 Binary files /dev/null and b/.github/assets/share-navigation-theme/tooltip-fixture-before-after.png differ 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..d68a77e506c --- /dev/null +++ b/apps/web/__tests__/unit/share-navigation.test.ts @@ -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(""); + }); +}); 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 eeca8be43ef..912299a2ff4 100644 --- a/apps/web/app/s/[videoId]/Share.tsx +++ b/apps/web/app/s/[videoId]/Share.tsx @@ -28,6 +28,7 @@ import type { OrganizationSettings } from "@/app/(org)/dashboard/dashboard-data" import { SignedImageUrl } from "@/components/SignedImageUrl"; import { CaptionProvider } from "./_components/CaptionContext"; 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"; @@ -761,7 +762,7 @@ export const Share = ({ */}
@@ -801,8 +802,9 @@ 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. -
+
+ diff --git a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx index 897c79f0b8e..9a4f533f666 100644 --- a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx @@ -60,6 +60,7 @@ import { import { usePublicEnv } from "@/utils/public-env"; import { navigateWithTransition } from "@/utils/view-transition"; import type { SharePageBranding, VideoData } from "../types"; +import { ShareNavigation } from "./ShareNavigation"; import { describeShareAudience } from "./share-audience"; import { useVideoDownload } from "./use-video-download"; import { fromNow } from "./utils/from-now"; @@ -669,7 +670,7 @@ export const ShareHeader = ({
{canManageSharePageBranding && (
-
+
{showCopyOptions && ( -
+