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
parent f0f9dd6656
commit 7a985f441c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 373 additions and 160 deletions

View File

@ -9,7 +9,7 @@ import { WhatsNewSection } from "@/components/landing/sections/whats-new-section
export default function LandingPage() { export default function LandingPage() {
return ( return (
<div className="min-h-screen w-full bg-[#0a0a0a]"> <div className="min-h-screen w-full overflow-x-clip bg-[#0a0a0a]">
<Header /> <Header />
<main className="flex w-full flex-col"> <main className="flex w-full flex-col">
<Hero /> <Hero />

View File

@ -8,6 +8,8 @@ import { getI18n } from "@/core/i18n/server";
import { env } from "@/env"; import { env } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { MobileNav } from "./mobile-nav";
export type HeaderProps = { export type HeaderProps = {
className?: string; className?: string;
homeURL?: string; homeURL?: string;
@ -21,20 +23,21 @@ export async function Header({ className, homeURL, locale }: HeaderProps) {
return ( return (
<header <header
className={cn( 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, className,
)} )}
> >
<div className="flex items-center gap-6"> <div className="flex min-w-0 items-center gap-6">
<a <a
href={homeURL ?? "https://github.com/bytedance/deer-flow"} href={homeURL ?? "https://github.com/bytedance/deer-flow"}
target={isExternalHome ? "_blank" : "_self"} target={isExternalHome ? "_blank" : "_self"}
rel={isExternalHome ? "noopener noreferrer" : undefined} rel={isExternalHome ? "noopener noreferrer" : undefined}
className="font-serif text-xl whitespace-nowrap"
> >
<h1 className="font-serif text-xl">DeerFlow</h1> DeerFlow
</a> </a>
</div> </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 <Link
href={`/${lang}/docs`} href={`/${lang}/docs`}
className="text-secondary-foreground hover:text-foreground transition-colors" className="text-secondary-foreground hover:text-foreground transition-colors"
@ -68,12 +71,18 @@ export async function Header({ className, homeURL, locale }: HeaderProps) {
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<GitHubLogoIcon className="size-4" /> <GitHubLogoIcon className="size-4" />
Star on GitHub <span className="hidden sm:inline">Star on GitHub</span>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
env.GITHUB_OAUTH_TOKEN && <StarCounter />} env.GITHUB_OAUTH_TOKEN && <StarCounter />}
</a> </a>
</Button> </Button>
</div> </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" /> <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> </header>
); );

View File

@ -1,15 +1,33 @@
"use client"; "use client";
import { ChevronRightIcon } from "lucide-react"; import { ChevronRightIcon } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react";
import { AuroraText } from "@/components/ui/aurora-text";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { FlickeringGrid } from "@/components/ui/flickering-grid"; import { FlickeringGrid } from "@/components/ui/flickering-grid";
import Galaxy from "@/components/ui/galaxy"; import Galaxy from "@/components/ui/galaxy";
import { WordRotate } from "@/components/ui/word-rotate";
import { env } from "@/env"; import { env } from "@/env";
import { cn } from "@/lib/utils"; 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 }) { export function Hero({ className }: { className?: string }) {
return ( return (
<div <div
@ -36,27 +54,14 @@ export function Hero({ className }: { className?: string }) {
maxOpacity={0.3} maxOpacity={0.3}
flickerChance={0.25} flickerChance={0.25}
/> />
<div className="container-md relative z-10 mx-auto flex h-screen flex-col items-center justify-center"> <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="flex items-center gap-2 text-4xl font-bold md:text-6xl"> <h1 className="text-center text-5xl leading-tight font-bold break-words md:text-6xl">
<WordRotate DeerFlow
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>
</h1> </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 && ( {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY && (
<a <a
href="https://byteplus.com" href="https://byteplus.com"
@ -69,17 +74,13 @@ export function Hero({ className }: { className?: string }) {
</div> </div>
</a> </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. An open-source SuperAgent harness that researches, codes, and creates.
With With the help of sandboxes, memories, tools, skills and subagents, it
<br /> handles different levels of tasks that could take minutes to hours.
the help of sandboxes, memories, tools, skills and subagents, it
handles
<br />
different levels of tasks that could take minutes to hours.
</p> </p>
<Link href="/workspace"> <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> <span className="text-md">Get Started with 2.0</span>
<ChevronRightIcon className="size-4" /> <ChevronRightIcon className="size-4" />
</Button> </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>) { function BytePlusIcon(props: React.SVGProps<SVGSVGElement>) {
return ( return (
<svg <svg

View File

@ -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>
);
}

View File

@ -12,18 +12,20 @@ export function Section({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className={cn("mx-auto flex flex-col py-16", className)}> <section
<header className="flex flex-col items-center justify-between"> className={cn("mx-auto flex w-full min-w-0 flex-col py-16", className)}
<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"> >
<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} {title}
</div> </div>
{subtitle && ( {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} {subtitle}
</div> </div>
)} )}
</header> </header>
<main className="mt-4">{children}</main> <main className="mt-4 w-full min-w-0 px-4">{children}</main>
</section> </section>
); );
} }

View File

@ -51,7 +51,7 @@ export function CaseStudySection({ className }: { className?: string }) {
title="Case Studies" title="Case Studies"
subtitle="See how DeerFlow is used in the wild" 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) => ( {caseStudies.map((caseStudy) => (
<Link <Link
key={caseStudy.title} key={caseStudy.title}

View File

@ -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>
);
}

View File

@ -17,17 +17,19 @@ export const ArtifactTrigger = () => {
return null; return null;
} }
return ( return (
<Tooltip content="Show artifacts of this conversation"> <Tooltip content={t.common.showArtifacts}>
<Button <Button
aria-label={t.common.showArtifacts}
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
variant="ghost" variant="ghost"
data-testid="artifact-trigger"
onClick={() => { onClick={() => {
sidecar?.close(); sidecar?.close();
setArtifactsOpen(true); setArtifactsOpen(true);
}} }}
> >
<FilesIcon /> <FilesIcon />
{t.common.artifacts} <span className="hidden sm:inline">{t.common.artifacts}</span>
</Button> </Button>
</Tooltip> </Tooltip>
); );

View File

@ -4,7 +4,15 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { ConversationEmptyState } from "@/components/ai-elements/conversation"; import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { env } from "@/env"; import { env } from "@/env";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
@ -24,6 +32,7 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
threadId, threadId,
}) => { }) => {
const { thread } = useThread(); const { thread } = useThread();
const isMobile = useIsMobile();
const pathname = usePathname(); const pathname = usePathname();
const threadIdRef = useRef(threadId); const threadIdRef = useRef(threadId);
@ -127,6 +136,102 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
} }
}, [artifactsOpen, setArtifactsOpen, sidecarOpen]); }, [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 ( return (
<div <div
id={`${resizableIdBase}-panels`} id={`${resizableIdBase}-panels`}
@ -163,49 +268,7 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
rightPanelOpen ? "opacity-100" : "opacity-0", rightPanelOpen ? "opacity-100" : "opacity-0",
)} )}
> >
{renderedRightPanel === "sidecar" ? ( {rightPanelContent}
<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}
</div> </div>
</aside> </aside>
</div> </div>

View File

@ -58,11 +58,12 @@ export function ExportTrigger({ threadId }: { threadId: string }) {
<Tooltip content={t.common.export}> <Tooltip content={t.common.export}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
aria-label={t.common.export}
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
variant="ghost" variant="ghost"
> >
<Download /> <Download />
{t.common.export} <span className="hidden sm:inline">{t.common.export}</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
</Tooltip> </Tooltip>

View File

@ -1,3 +1,4 @@
import { CalendarClock } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -8,9 +9,11 @@ export function ThreadScheduledTasksLink({ threadId }: { threadId: string }) {
return ( return (
<Button variant="outline" size="sm" asChild> <Button variant="outline" size="sm" asChild>
<Link <Link
aria-label={t.sidebar.scheduledTasks}
href={`/workspace/scheduled-tasks?thread_id=${encodeURIComponent(threadId)}`} href={`/workspace/scheduled-tasks?thread_id=${encodeURIComponent(threadId)}`}
> >
{t.sidebar.scheduledTasks} <CalendarClock />
<span className="hidden sm:inline">{t.sidebar.scheduledTasks}</span>
</Link> </Link>
</Button> </Button>
); );

View File

@ -51,6 +51,7 @@ export const enUS: Translations = {
exportAsJSON: "Export as JSON", exportAsJSON: "Export as JSON",
exportSuccess: "Conversation exported", exportSuccess: "Conversation exported",
regenerate: "Regenerate", regenerate: "Regenerate",
showArtifacts: "Show artifacts of this conversation",
}, },
// Home // Home

View File

@ -40,6 +40,7 @@ export interface Translations {
exportAsJSON: string; exportAsJSON: string;
exportSuccess: string; exportSuccess: string;
regenerate: string; regenerate: string;
showArtifacts: string;
}; };
home: { home: {

View File

@ -51,6 +51,7 @@ export const zhCN: Translations = {
exportAsJSON: "导出为 JSON", exportAsJSON: "导出为 JSON",
exportSuccess: "对话已导出", exportSuccess: "对话已导出",
regenerate: "重新生成", regenerate: "重新生成",
showArtifacts: "查看此对话的文件",
}, },
// Home // Home

View File

@ -1,21 +1,22 @@
import * as React from "react"; import * as React from "react";
const MOBILE_BREAKPOINT = 768; 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() { export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>( return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
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;
} }

View File

@ -241,7 +241,7 @@
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0.0098 87.47); --border: oklch(0.922 0.0098 87.47);
--input: oklch(0.88 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-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704); --chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392); --chart-3: oklch(0.398 0.07 227.392);
@ -275,7 +275,7 @@
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --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-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48); --chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08); --chart-3: oklch(0.769 0.188 70.08);

View File

@ -6,10 +6,11 @@ test.describe("Landing page", () => {
test("renders the header and hero section", async ({ page }) => { test("renders the header and hero section", async ({ page }) => {
await page.goto("/"); await page.goto("/");
// Header brand name
await expect( await expect(
page.locator("header h1", { hasText: "DeerFlow" }), page.locator("header").first().getByText("DeerFlow", { exact: true }),
).toBeVisible(); ).toBeVisible();
await expect(page.locator("h1")).toHaveCount(1);
await expect(page.locator("h1")).toContainText("DeerFlow");
// "Get Started" call-to-action button in hero // "Get Started" call-to-action button in hero
await expect( await expect(
@ -17,6 +18,18 @@ test.describe("Landing page", () => {
).toBeVisible(); ).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 }) => { test("Get Started link navigates to workspace", async ({ page }) => {
mockLangGraphAPI(page); mockLangGraphAPI(page);

View File

@ -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);
});
});