Fix(frontend): Fix mobile workspace and accessibility blockers (#3740)

* fix(frontend): address mobile workspace polish blockers

* fix(frontend): prevent mobile landing overflow

* fix(frontend): keep landing sections within mobile viewport

Section titles used a fixed text-5xl with no word breaking, and the
<section> flex items had default min-width:auto, so wider Linux font
metrics in CI pushed content past the viewport (scrollWidth 345 > 320).
Make titles/subtitles responsive with break-words, constrain section
width with min-w-0, and add an overflow-x-clip guard on the page root.

* fix(frontend): address review feedback on mobile landing PR

- add hamburger Sheet nav below sm: so mobile users keep docs/blog access
- switch useIsMobile to useSyncExternalStore to avoid hydration swap flash
- memoize artifactContent so it isn't rebuilt on every streamed token
- use slot-based key in HeroWordRotate; drop flex-wrap so SuperAgent never orphans at 320px
- delete unused word-rotate.tsx dead code
- move section gutter to <main> for a uniform mobile padding contract
- harden e2e: per-viewport overflow tests, locale-stable artifact selector, focus-ring asserts light vs dark differ

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Ryker_Feng
2026-07-06 07:15:35 +08:00
committed by GitHub
co-authored by Willem Jiang
parent f0f9dd6656
commit 7a985f441c
18 changed files with 373 additions and 160 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ import { WhatsNewSection } from "@/components/landing/sections/whats-new-section
export default function LandingPage() {
return (
<div className="min-h-screen w-full bg-[#0a0a0a]">
<div className="min-h-screen w-full overflow-x-clip bg-[#0a0a0a]">
<Header />
<main className="flex w-full flex-col">
<Hero />
+14 -5
View File
@@ -8,6 +8,8 @@ import { getI18n } from "@/core/i18n/server";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import { MobileNav } from "./mobile-nav";
export type HeaderProps = {
className?: string;
homeURL?: string;
@@ -21,20 +23,21 @@ export async function Header({ className, homeURL, locale }: HeaderProps) {
return (
<header
className={cn(
"container-md fixed top-0 right-0 left-0 z-20 mx-auto flex h-16 items-center justify-between backdrop-blur-xs",
"container-md fixed top-0 right-0 left-0 z-20 mx-auto flex h-16 items-center justify-between gap-3 px-4 backdrop-blur-xs",
className,
)}
>
<div className="flex items-center gap-6">
<div className="flex min-w-0 items-center gap-6">
<a
href={homeURL ?? "https://github.com/bytedance/deer-flow"}
target={isExternalHome ? "_blank" : "_self"}
rel={isExternalHome ? "noopener noreferrer" : undefined}
className="font-serif text-xl whitespace-nowrap"
>
<h1 className="font-serif text-xl">DeerFlow</h1>
DeerFlow
</a>
</div>
<nav className="mr-8 ml-auto flex items-center gap-8 text-sm font-medium">
<nav className="ml-auto hidden items-center gap-5 text-sm font-medium sm:flex md:mr-8 md:gap-8">
<Link
href={`/${lang}/docs`}
className="text-secondary-foreground hover:text-foreground transition-colors"
@@ -68,12 +71,18 @@ export async function Header({ className, homeURL, locale }: HeaderProps) {
rel="noopener noreferrer"
>
<GitHubLogoIcon className="size-4" />
Star on GitHub
<span className="hidden sm:inline">Star on GitHub</span>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
env.GITHUB_OAUTH_TOKEN && <StarCounter />}
</a>
</Button>
</div>
<MobileNav
links={[
{ href: `/${lang}/docs`, label: t.home.docs },
{ href: "/blog/posts", label: t.home.blog },
]}
/>
<hr className="from-border/0 via-border/70 to-border/0 absolute top-16 right-0 left-0 z-10 m-0 h-px w-full border-none bg-linear-to-r" />
</header>
);
+71 -29
View File
@@ -1,15 +1,33 @@
"use client";
import { ChevronRightIcon } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { AuroraText } from "@/components/ui/aurora-text";
import { Button } from "@/components/ui/button";
import { FlickeringGrid } from "@/components/ui/flickering-grid";
import Galaxy from "@/components/ui/galaxy";
import { WordRotate } from "@/components/ui/word-rotate";
import { env } from "@/env";
import { cn } from "@/lib/utils";
const HERO_WORDS = [
"Deep Research",
"Collect Data",
"Analyze Data",
"Generate Webpages",
"Vibe Coding",
"Generate Slides",
"Generate Images",
"Generate Podcasts",
"Generate Videos",
"Generate Songs",
"Organize Emails",
"Do Anything",
"Learn Anything",
];
export function Hero({ className }: { className?: string }) {
return (
<div
@@ -36,27 +54,14 @@ export function Hero({ className }: { className?: string }) {
maxOpacity={0.3}
flickerChance={0.25}
/>
<div className="container-md relative z-10 mx-auto flex h-screen flex-col items-center justify-center">
<h1 className="flex items-center gap-2 text-4xl font-bold md:text-6xl">
<WordRotate
words={[
"Deep Research",
"Collect Data",
"Analyze Data",
"Generate Webpages",
"Vibe Coding",
"Generate Slides",
"Generate Images",
"Generate Podcasts",
"Generate Videos",
"Generate Songs",
"Organize Emails",
"Do Anything",
"Learn Anything",
]}
/>{" "}
<div>with DeerFlow</div>
<div className="container-md relative z-10 mx-auto flex min-h-[92svh] flex-col items-center justify-center px-4 pt-20 pb-14">
<h1 className="text-center text-5xl leading-tight font-bold break-words md:text-6xl">
DeerFlow
</h1>
<div className="mt-3 flex w-full max-w-full min-w-0 items-center justify-center gap-x-2 text-center text-2xl font-semibold md:text-4xl">
<HeroWordRotate words={HERO_WORDS} />
<span className="whitespace-nowrap">SuperAgent</span>
</div>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY && (
<a
href="https://byteplus.com"
@@ -69,17 +74,13 @@ export function Hero({ className }: { className?: string }) {
</div>
</a>
)}
<p className="text-muted-foreground mt-8 scale-105 text-center text-2xl text-shadow-sm">
<p className="text-muted-foreground mt-8 max-w-4xl text-center text-base leading-7 text-shadow-sm sm:text-xl md:text-2xl">
An open-source SuperAgent harness that researches, codes, and creates.
With
<br />
the help of sandboxes, memories, tools, skills and subagents, it
handles
<br />
different levels of tasks that could take minutes to hours.
With the help of sandboxes, memories, tools, skills and subagents, it
handles different levels of tasks that could take minutes to hours.
</p>
<Link href="/workspace">
<Button className="size-lg mt-8 scale-108" size="lg">
<Button className="mt-8 h-11 px-5" size="lg">
<span className="text-md">Get Started with 2.0</span>
<ChevronRightIcon className="size-4" />
</Button>
@@ -89,6 +90,47 @@ export function Hero({ className }: { className?: string }) {
);
}
function HeroWordRotate({
words,
duration = 2200,
}: {
words: string[];
duration?: number;
}) {
const [index, setIndex] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setIndex((prevIndex) => (prevIndex + 1) % words.length);
}, duration);
return () => clearInterval(interval);
}, [words, duration]);
return (
<div className="relative max-w-full min-w-0 overflow-hidden py-2">
<AnimatePresence mode="popLayout">
<motion.div
key={index}
className="max-w-full"
initial={{ opacity: 0, y: -50, filter: "blur(16px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, y: 50, filter: "blur(16px)" }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<AuroraText
className="max-w-full [overflow-wrap:anywhere] whitespace-normal"
speed={3}
colors={["#efefbb", "#e9c665", "#e3a812"]}
>
{words[index]}
</AuroraText>
</motion.div>
</AnimatePresence>
</div>
);
}
function BytePlusIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
@@ -0,0 +1,49 @@
"use client";
import { MenuIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
export type MobileNavLink = {
href: string;
label: string;
};
export function MobileNav({ links }: { links: MobileNavLink[] }) {
const [open, setOpen] = useState(false);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild className="sm:hidden">
<Button variant="ghost" size="icon" aria-label="Open menu">
<MenuIcon className="size-5" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-64">
<SheetHeader>
<SheetTitle className="font-serif text-xl">DeerFlow</SheetTitle>
</SheetHeader>
<nav className="flex flex-col gap-1 px-4 text-base font-medium">
{links.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={() => setOpen(false)}
className="text-secondary-foreground hover:text-foreground rounded-md py-2 transition-colors"
>
{link.label}
</Link>
))}
</nav>
</SheetContent>
</Sheet>
);
}
+7 -5
View File
@@ -12,18 +12,20 @@ export function Section({
children: React.ReactNode;
}) {
return (
<section className={cn("mx-auto flex flex-col py-16", className)}>
<header className="flex flex-col items-center justify-between">
<div className="mb-4 bg-linear-to-r from-white via-gray-200 to-gray-400 bg-clip-text text-center text-5xl font-bold text-transparent">
<section
className={cn("mx-auto flex w-full min-w-0 flex-col py-16", className)}
>
<header className="flex flex-col items-center justify-between px-4">
<div className="mb-4 max-w-full bg-linear-to-r from-white via-gray-200 to-gray-400 bg-clip-text text-center text-3xl font-bold break-words text-transparent sm:text-4xl md:text-5xl">
{title}
</div>
{subtitle && (
<div className="text-muted-foreground text-center text-xl">
<div className="text-muted-foreground max-w-full text-center text-base break-words sm:text-lg md:text-xl">
{subtitle}
</div>
)}
</header>
<main className="mt-4">{children}</main>
<main className="mt-4 w-full min-w-0 px-4">{children}</main>
</section>
);
}
@@ -51,7 +51,7 @@ export function CaseStudySection({ className }: { className?: string }) {
title="Case Studies"
subtitle="See how DeerFlow is used in the wild"
>
<div className="container-md mt-8 grid grid-cols-1 gap-4 px-4 md:grid-cols-2 md:px-20 lg:grid-cols-3">
<div className="container-md mt-8 grid grid-cols-1 gap-4 md:grid-cols-2 md:px-20 lg:grid-cols-3">
{caseStudies.map((caseStudy) => (
<Link
key={caseStudy.title}
@@ -1,53 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { AnimatePresence, motion, type MotionProps } from "motion/react";
import { cn } from "@/lib/utils";
import { AuroraText } from "./aurora-text";
interface WordRotateProps {
words: string[];
duration?: number;
motionProps?: MotionProps;
className?: string;
}
export function WordRotate({
words,
duration = 2200,
motionProps = {
initial: { opacity: 0, y: -50, filter: "blur(16px)" },
animate: { opacity: 1, y: 0, filter: "blur(0px)" },
exit: { opacity: 0, y: 50, filter: "blur(16px)" },
transition: { duration: 0.3, ease: "easeOut" },
},
className,
}: WordRotateProps) {
const [index, setIndex] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setIndex((prevIndex) => (prevIndex + 1) % words.length);
}, duration);
// Clean up interval on unmount
return () => clearInterval(interval);
}, [words, duration]);
return (
<div className="overflow-hidden py-2">
<AnimatePresence mode="popLayout">
<motion.h1
key={words[index]}
className={cn(className)}
{...motionProps}
>
<AuroraText speed={3} colors={["#efefbb", "#e9c665", "#e3a812"]}>
{words[index]}
</AuroraText>
</motion.h1>
</AnimatePresence>
</div>
);
}
@@ -17,17 +17,19 @@ export const ArtifactTrigger = () => {
return null;
}
return (
<Tooltip content="Show artifacts of this conversation">
<Tooltip content={t.common.showArtifacts}>
<Button
aria-label={t.common.showArtifacts}
className="text-muted-foreground hover:text-foreground"
variant="ghost"
data-testid="artifact-trigger"
onClick={() => {
sidecar?.close();
setArtifactsOpen(true);
}}
>
<FilesIcon />
{t.common.artifacts}
<span className="hidden sm:inline">{t.common.artifacts}</span>
</Button>
</Tooltip>
);
@@ -4,7 +4,15 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { env } from "@/env";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import {
@@ -24,6 +32,7 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
threadId,
}) => {
const { thread } = useThread();
const isMobile = useIsMobile();
const pathname = usePathname();
const threadIdRef = useRef(threadId);
@@ -127,6 +136,102 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
}
}, [artifactsOpen, setArtifactsOpen, sidecarOpen]);
const rightPanelContent = useMemo(() => {
if (renderedRightPanel === "sidecar") {
return <SidecarPanel />;
}
if (renderedRightPanel === "artifacts" && selectedArtifact) {
return (
<ArtifactFileDetail
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
/>
);
}
if (renderedRightPanel === "artifacts") {
return (
<div className="relative flex size-full justify-center">
<div className="absolute top-1 right-1 z-30">
<Button
size="icon-sm"
variant="ghost"
onClick={() => {
setArtifactsOpen(false);
}}
>
<XIcon />
</Button>
</div>
{artifacts.length === 0 ? (
<ConversationEmptyState
icon={<FilesIcon />}
title="No artifact selected"
description="Select an artifact to view its details"
/>
) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
<header className="shrink-0">
<h2 className="text-lg font-medium">Artifacts</h2>
</header>
<main className="min-h-0 grow">
<ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12"
files={artifacts}
threadId={threadId}
/>
</main>
</div>
)}
</div>
);
}
return null;
}, [
renderedRightPanel,
selectedArtifact,
threadId,
artifacts,
setArtifactsOpen,
]);
if (isMobile) {
return (
<>
<div className="relative size-full min-w-0">{children}</div>
<Sheet
open={rightPanelOpen}
onOpenChange={(open) => {
if (open) {
return;
}
if (sidecarOpen) {
sidecar?.close();
}
if (artifactsOpen) {
setArtifactsOpen(false);
}
}}
>
<SheetContent
className="w-[calc(100vw-1rem)] max-w-none gap-0 p-0 sm:max-w-md [&>button]:hidden"
side="right"
>
<SheetHeader className="sr-only">
<SheetTitle>
{renderedRightPanel === "sidecar" ? "Sidecar" : "Artifacts"}
</SheetTitle>
<SheetDescription>
Browse the side panel for this conversation.
</SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1 p-3 pt-10">{rightPanelContent}</div>
</SheetContent>
</Sheet>
</>
);
}
return (
<div
id={`${resizableIdBase}-panels`}
@@ -163,49 +268,7 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
rightPanelOpen ? "opacity-100" : "opacity-0",
)}
>
{renderedRightPanel === "sidecar" ? (
<SidecarPanel />
) : renderedRightPanel === "artifacts" && selectedArtifact ? (
<ArtifactFileDetail
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
/>
) : renderedRightPanel === "artifacts" ? (
<div className="relative flex size-full justify-center">
<div className="absolute top-1 right-1 z-30">
<Button
size="icon-sm"
variant="ghost"
onClick={() => {
setArtifactsOpen(false);
}}
>
<XIcon />
</Button>
</div>
{artifacts.length === 0 ? (
<ConversationEmptyState
icon={<FilesIcon />}
title="No artifact selected"
description="Select an artifact to view its details"
/>
) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
<header className="shrink-0">
<h2 className="text-lg font-medium">Artifacts</h2>
</header>
<main className="min-h-0 grow">
<ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12"
files={artifacts}
threadId={threadId}
/>
</main>
</div>
)}
</div>
) : null}
{rightPanelContent}
</div>
</aside>
</div>
@@ -58,11 +58,12 @@ export function ExportTrigger({ threadId }: { threadId: string }) {
<Tooltip content={t.common.export}>
<DropdownMenuTrigger asChild>
<Button
aria-label={t.common.export}
className="text-muted-foreground hover:text-foreground"
variant="ghost"
>
<Download />
{t.common.export}
<span className="hidden sm:inline">{t.common.export}</span>
</Button>
</DropdownMenuTrigger>
</Tooltip>
@@ -1,3 +1,4 @@
import { CalendarClock } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
@@ -8,9 +9,11 @@ export function ThreadScheduledTasksLink({ threadId }: { threadId: string }) {
return (
<Button variant="outline" size="sm" asChild>
<Link
aria-label={t.sidebar.scheduledTasks}
href={`/workspace/scheduled-tasks?thread_id=${encodeURIComponent(threadId)}`}
>
{t.sidebar.scheduledTasks}
<CalendarClock />
<span className="hidden sm:inline">{t.sidebar.scheduledTasks}</span>
</Link>
</Button>
);
+1
View File
@@ -51,6 +51,7 @@ export const enUS: Translations = {
exportAsJSON: "Export as JSON",
exportSuccess: "Conversation exported",
regenerate: "Regenerate",
showArtifacts: "Show artifacts of this conversation",
},
// Home
+1
View File
@@ -40,6 +40,7 @@ export interface Translations {
exportAsJSON: string;
exportSuccess: string;
regenerate: string;
showArtifacts: string;
};
home: {
+1
View File
@@ -51,6 +51,7 @@ export const zhCN: Translations = {
exportAsJSON: "导出为 JSON",
exportSuccess: "对话已导出",
regenerate: "重新生成",
showArtifacts: "查看此对话的文件",
},
// Home
+16 -15
View File
@@ -1,21 +1,22 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
const MOBILE_QUERY = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`;
function subscribe(callback: () => void) {
const mql = window.matchMedia(MOBILE_QUERY);
mql.addEventListener("change", callback);
return () => mql.removeEventListener("change", callback);
}
function getSnapshot() {
return window.matchMedia(MOBILE_QUERY).matches;
}
function getServerSnapshot() {
return false;
}
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
+2 -2
View File
@@ -241,7 +241,7 @@
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0.0098 87.47);
--input: oklch(0.88 0.0098 87.47);
--ring: transparent;
--ring: oklch(0.58 0.18 255);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
@@ -275,7 +275,7 @@
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: transparent;
--ring: oklch(0.74 0.16 255);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
+15 -2
View File
@@ -6,10 +6,11 @@ test.describe("Landing page", () => {
test("renders the header and hero section", async ({ page }) => {
await page.goto("/");
// Header brand name
await expect(
page.locator("header h1", { hasText: "DeerFlow" }),
page.locator("header").first().getByText("DeerFlow", { exact: true }),
).toBeVisible();
await expect(page.locator("h1")).toHaveCount(1);
await expect(page.locator("h1")).toContainText("DeerFlow");
// "Get Started" call-to-action button in hero
await expect(
@@ -17,6 +18,18 @@ test.describe("Landing page", () => {
).toBeVisible();
});
for (const width of [320, 375, 390]) {
test(`does not overflow at ${width}px width`, async ({ page }) => {
await page.setViewportSize({ width, height: 812 });
await page.goto("/");
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth))
.toBeLessThanOrEqual(width);
await expect(page.locator("main").first()).toBeInViewport();
});
}
test("Get Started link navigates to workspace", async ({ page }) => {
mockLangGraphAPI(page);
@@ -0,0 +1,78 @@
import { expect, test } from "@playwright/test";
import { MOCK_THREAD_ID, mockLangGraphAPI } from "./utils/mock-api";
test.describe("UI polish mobile regressions", () => {
test("workspace exposes mobile sidebar navigation from the chat header", async ({
page,
}) => {
await page.setViewportSize({ width: 375, height: 812 });
mockLangGraphAPI(page);
await page.goto("/workspace/chats/new");
await page.getByRole("button", { name: /toggle sidebar/i }).click();
await expect(page.getByRole("link", { name: /new chat/i })).toBeVisible();
await expect(page.getByRole("link", { name: /agents/i })).toBeVisible();
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth))
.toBeLessThanOrEqual(375);
});
test("mobile artifacts open in a drawer without horizontal overflow", async ({
page,
}) => {
await page.setViewportSize({ width: 375, height: 812 });
mockLangGraphAPI(page, {
threads: [
{
thread_id: MOCK_THREAD_ID,
title: "Thread with artifact",
artifacts: ["reports/mobile-summary.md"],
},
],
});
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
await page.getByTestId("artifact-trigger").click();
await expect(
page.getByRole("dialog", { name: /artifacts/i }),
).toBeVisible();
await expect(page.getByText("mobile-summary.md")).toBeVisible();
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth))
.toBeLessThanOrEqual(375);
});
test("global focus ring tokens are visible in light and dark themes", async ({
page,
}) => {
mockLangGraphAPI(page);
await page.goto("/workspace/chats/new");
const readRing = () =>
page.evaluate(() =>
getComputedStyle(document.documentElement)
.getPropertyValue("--ring")
.trim(),
);
await page.evaluate(() =>
document.documentElement.classList.remove("dark"),
);
const lightRing = await readRing();
expect(lightRing).not.toBe("transparent");
expect(lightRing).not.toBe("");
await page.evaluate(() => document.documentElement.classList.add("dark"));
const darkRing = await readRing();
expect(darkRing).not.toBe("transparent");
expect(darkRing).not.toBe("");
// The two themes must resolve to different ring tokens, otherwise the test
// would pass trivially if <html> were stuck in one mode.
expect(darkRing).not.toBe(lightRing);
});
});