Skip to content
Merged
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
5 changes: 3 additions & 2 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,8 @@ function RootRouteShell(props: {
onToggleLeftSidebarCollapsed: () => void;
}) {
return (
<div className="bg-surface-primary flex flex-1 flex-col overflow-hidden">
// The page has no chat pane here, so the shell is its one `main` landmark (#5969).
<main className="bg-surface-primary flex flex-1 flex-col overflow-hidden">
{props.leftSidebarCollapsed ? (
<div
className={`bg-sidebar border-border-light flex shrink-0 items-center border-b px-4 py-2 ${isDesktopMode() ? "titlebar-drag" : ""}`}
Expand All @@ -158,7 +159,7 @@ function RootRouteShell(props: {
surface, below WCAG AA for text-sm (#5948). */}
<p className="text-content-secondary text-sm">Select or add a project to get started.</p>
</div>
</div>
</main>
);
}

Expand Down
5 changes: 3 additions & 2 deletions src/browser/components/ProjectPage/ProjectPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,8 @@ export const ProjectPage: React.FC<ProjectPageProps> = ({
<AgentProvider projectPath={projectPath}>
<ThinkingProvider projectPath={projectPath}>
{/* Flex container to fill parent space */}
<div className="bg-surface-primary relative flex flex-1 flex-col overflow-hidden">
{/* No workspace is open, so this page is the one `main` landmark (#5969). */}
<main className="bg-surface-primary relative flex flex-1 flex-col overflow-hidden">
{/* Draggable header bar - matches WorkspaceMenuBar for consistency */}
<div
className={cn(
Expand Down Expand Up @@ -403,7 +404,7 @@ export const ProjectPage: React.FC<ProjectPageProps> = ({
projectName={projectName}
/>
</div>
</div>
</main>
</ThinkingProvider>
</AgentProvider>
);
Expand Down
5 changes: 3 additions & 2 deletions src/browser/components/ScratchPage/ScratchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ export function ScratchPage(props: ScratchPageProps) {
return (
<AgentProvider projectPath={SCRATCH_PROJECT_CONFIG_KEY}>
<ThinkingProvider projectPath={SCRATCH_PROJECT_CONFIG_KEY}>
<div className="bg-surface-primary relative flex flex-1 flex-col overflow-hidden">
{/* No workspace is open, so this page is the one `main` landmark (#5969). */}
<main className="bg-surface-primary relative flex flex-1 flex-col overflow-hidden">
<div
className={cn(
"bg-sidebar border-border-light mobile-sticky-header flex shrink-0 items-center border-b px-2 [@media(max-width:768px)]:h-auto [@media(max-width:768px)]:py-2",
Expand Down Expand Up @@ -146,7 +147,7 @@ export function ScratchPage(props: ScratchPageProps) {
</div>
</div>
</div>
</div>
</main>
</ThinkingProvider>
</AgentProvider>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ function findReviewHunkId(review: Review, fileHunks: DiffHunk[]): string | null
}

export const ImmersiveReviewView: React.FC<ImmersiveReviewViewProps> = (props) => {
const containerRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const notesSidebarRef = useRef<HTMLDivElement>(null);
const hunkJumpScrollBlockRef = useRef<ScrollLogicalPosition>("center");
Expand Down Expand Up @@ -2072,7 +2072,9 @@ export const ImmersiveReviewView: React.FC<ImmersiveReviewViewProps> = (props) =
: "empty";

return (
<div
// While immersive review is open the chat pane (and its `main`) is hidden and inert, so this
// view is the page's one `main` landmark (landmark-one-main, #5969).
<main
ref={containerRef}
tabIndex={isTouchExperience ? -1 : 0}
className="flex h-full flex-col overflow-hidden outline-none"
Expand Down Expand Up @@ -2650,6 +2652,6 @@ export const ImmersiveReviewView: React.FC<ImmersiveReviewViewProps> = (props) =
</div>
</>
)}
</div>
</main>
);
};
162 changes: 158 additions & 4 deletions src/browser/stories/App.seededWorkspaceA11y.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,23 @@
* Seeded workspace page (#5951): the ARIA and target-size audits that Lighthouse failed on a
* workspace with a short chat. The play checks the same facts with role queries, in a real
* browser so the toggle's hit area is measured. Contrast is a separate decision (#5950). The
* phone stories cover the main landmark at 390 px (#5956).
* phone stories cover the main landmark at 390 px (#5956). The landmark stories at the end cover
* the layouts without a chat pane: immersive review and the pages with no workspace (#5969).
*/

import { expect, waitFor, within } from "@storybook/test";
import { expect, userEvent, waitFor, within } from "@storybook/test";

import { updatePersistedState } from "@/browser/hooks/usePersistedState";
import { RIGHT_SIDEBAR_WIDTH_KEY } from "@/common/constants/storage";
import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events";
import { getReviewImmersiveKey, RIGHT_SIDEBAR_WIDTH_KEY } from "@/common/constants/storage";
import type { ProjectConfig } from "@/node/config";

import type { ThemeMode } from "@/browser/contexts/ThemeContext";

import { appMeta, AppWithMocks, PIXEL_DISABLED, type AppStory } from "./meta.js";
import { setupSimpleChatStory } from "./helpers/chatSetup";
import { expandLeftSidebar, expandRightSidebar } from "./helpers/uiState";
import { expandLeftSidebar, expandProjects, expandRightSidebar } from "./helpers/uiState";
import { createMockORPCClient } from "./mocks/orpc";
import { createAssistantMessage, createUserMessage } from "./mocks/messages";
import { STABLE_TIMESTAMP } from "./mocks/workspaces";

Expand Down Expand Up @@ -226,3 +230,153 @@ const phoneStory = (theme: ThemeMode): AppStory => ({

export const PhoneLight = phoneStory("light");
export const PhoneDark = phoneStory("dark");

/**
* Layouts without a chat pane (#5969): immersive review hides the chat, and the root, project and
* scratch pages have no workspace. Each still needs exactly one `main` landmark, holding its
* primary content and none of the sidebars. Where a footer exists it stays outside `main`.
*/
async function expectOneMainHolding(canvasElement: HTMLElement, content: HTMLElement) {
const canvas = within(canvasElement);
const mains = canvas.getAllByRole("main");
await expect(mains).toHaveLength(1);
await expect(mains[0]).toContainElement(content);
const main = within(mains[0]);
await expect(main.queryByRole("navigation", { name: "Projects" })).toBeNull();
await expect(main.queryByRole("complementary", { name: "Workspace insights" })).toBeNull();
await expect(main.queryByRole("contentinfo")).toBeNull();
}

const REVIEW_WORKSPACE_ID = "ws-a11y-review";
const REVIEW_DIFF = `diff --git a/src/a11y/landmarks.ts b/src/a11y/landmarks.ts
index 1111111..2222222 100644
--- a/src/a11y/landmarks.ts
+++ b/src/a11y/landmarks.ts
@@ -1,3 +1,4 @@
export const LANDMARKS = ["main"];
+export const ONE_MAIN = true;
export const VERSION = 1;
`;
const REVIEW_NUMSTAT = "1\t0\tsrc/a11y/landmarks.ts";

const PROJECT_PATH = "/home/user/projects/xum";

type LayoutPlay = (canvasElement: HTMLElement, layout: "desktop" | "phone") => Promise<HTMLElement>;

interface LandmarkLayout {
setup: () => ReturnType<typeof createMockORPCClient>;
/** Opens the layout and returns an element of its primary content. */
open: LayoutPlay;
}

const LANDMARK_LAYOUTS: Record<
"ImmersiveReview" | "RootPage" | "ProjectPage" | "ScratchPage",
LandmarkLayout
> = {
ImmersiveReview: {
setup: () => {
const client = setupSimpleChatStory({
workspaceId: REVIEW_WORKSPACE_ID,
workspaceName: "a11y-review",
projectName: "xum",
projectPath: PROJECT_PATH,
messages: [
createUserMessage("msg-1", "Review the change", {
historySequence: 1,
timestamp: STABLE_TIMESTAMP - 60_000,
}),
],
gitDiff: { diffOutput: REVIEW_DIFF, numstatOutput: REVIEW_NUMSTAT },
});
expandRightSidebar();
// Immersive mode is persisted per workspace, so start each story with the chat showing.
updatePersistedState(getReviewImmersiveKey(REVIEW_WORKSPACE_ID), false);
return client;
},
open: async (canvasElement, layout) => {
const canvas = within(canvasElement);
await canvas.findByRole("textbox", { name: "Message" }, { timeout: 15_000 });
window.dispatchEvent(
createCustomEvent(
layout === "phone"
? CUSTOM_EVENTS.OPEN_TOUCH_REVIEW_IMMERSIVE
: CUSTOM_EVENTS.OPEN_REVIEW_IMMERSIVE,
{ workspaceId: REVIEW_WORKSPACE_ID }
)
);
return canvas.findByTestId("immersive-review-view", {}, { timeout: 10_000 });
},
},
RootPage: {
setup: () => createMockORPCClient({}),
open: (canvasElement) =>
within(canvasElement).findByText(
"Select or add a project to get started.",
{},
{ timeout: 15_000 }
),
},
ProjectPage: {
setup: () => {
expandLeftSidebar();
expandProjects([PROJECT_PATH]);
return createMockORPCClient({
projects: new Map<string, ProjectConfig>([[PROJECT_PATH, { workspaces: [] }]]),
workspaces: [],
});
},
open: async (canvasElement) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
await body.findByRole("button", { name: "Create workspace in xum" }, { timeout: 15_000 })
);
return body.findByRole("textbox", { name: "Message" });
},
},
ScratchPage: {
setup: () => {
expandLeftSidebar();
return createMockORPCClient({});
},
open: async (canvasElement) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
await body.findByRole("button", { name: "New scratch chat" }, { timeout: 15_000 })
);
return body.findByRole("textbox", { name: "Message" });
},
},
};

const landmarkStory = (
name: keyof typeof LANDMARK_LAYOUTS,
layout: "desktop" | "phone"
): AppStory => ({
...(layout === "phone"
? {
globals: { viewport: { value: "mobile2", isRotated: false } },
decorators: [
(Story) => (
<div style={{ width: PHONE_WIDTH_PX, height: 844, overflow: "hidden" }}>
<Story />
</div>
),
],
}
: {}),
parameters: { ...appMeta.parameters, pixel: PIXEL_DISABLED },
render: () => <AppWithMocks setup={LANDMARK_LAYOUTS[name].setup} />,
play: async ({ canvasElement }) => {
const content = await LANDMARK_LAYOUTS[name].open(canvasElement, layout);
await waitFor(() => expectOneMainHolding(canvasElement, content));
},
});

export const ImmersiveReviewLandmark = landmarkStory("ImmersiveReview", "desktop");
export const ImmersiveReviewLandmarkPhone = landmarkStory("ImmersiveReview", "phone");
export const RootPageLandmark = landmarkStory("RootPage", "desktop");
export const RootPageLandmarkPhone = landmarkStory("RootPage", "phone");
export const ProjectPageLandmark = landmarkStory("ProjectPage", "desktop");
export const ProjectPageLandmarkPhone = landmarkStory("ProjectPage", "phone");
export const ScratchPageLandmark = landmarkStory("ScratchPage", "desktop");
export const ScratchPageLandmarkPhone = landmarkStory("ScratchPage", "phone");
Loading