+
Room:
@@ -80,6 +89,8 @@ export function GamePageDesktopHeader() {
+
+
);
diff --git a/src/pages/game/ui/GamePageHomeAction.tsx b/src/pages/game/ui/GamePageHomeAction.tsx
new file mode 100644
index 0000000..4081ba8
--- /dev/null
+++ b/src/pages/game/ui/GamePageHomeAction.tsx
@@ -0,0 +1,25 @@
+import { Link } from "@tanstack/react-router";
+import { Home } from "lucide-react";
+
+import {
+ GAME_PAGE_CONTROLS,
+ GAME_PAGE_HOME_ACTION_TEST_IDS,
+} from "@/pages/game/config";
+import { Button } from "@/shared/ui";
+import { MARKETING_ROUTES } from "@/widgets/marketing-shell";
+
+export function GamePageHomeAction() {
+ return (
+
+ );
+}
diff --git a/src/pages/game/ui/GamePageMobileActionPanel.test.tsx b/src/pages/game/ui/GamePageMobileActionPanel.test.tsx
index 1370e74..784028a 100644
--- a/src/pages/game/ui/GamePageMobileActionPanel.test.tsx
+++ b/src/pages/game/ui/GamePageMobileActionPanel.test.tsx
@@ -2,7 +2,15 @@
import { render, screen, within } from "@testing-library/react";
import type { ButtonHTMLAttributes, ReactNode } from "react";
-import { describe, expect, it, vi } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import {
+ GAME_PAGE_MOBILE_ACTION_PANEL_CLASS_NAMES,
+ GAME_PAGE_MOBILE_ACTION_PANEL_TEST_IDS,
+ GAME_PAGE_MOBILE_TOUCH_ACTION_PANEL_CLASS_NAMES,
+ GAME_PAGE_MOBILE_TOUCH_ACTION_PANEL_TEST_IDS,
+} from "@/pages/game/config";
+import type { GamePageMobileActionPanelModel } from "@/pages/game/model";
import { GamePageMobileActionPanel } from "./GamePageMobileActionPanel";
@@ -10,36 +18,48 @@ const handleTouchAttack = vi.fn();
const handleTouchInteract = vi.fn();
const handleAudioMuteToggle = vi.fn();
+let mockIsTabletLayout = true;
+
+const mockTouchActions: GamePageMobileActionPanelModel["touchActions"] = {
+ attack: {
+ handleTouchAttack,
+ hasTouchAttack: true,
+ touchAttackPrompt: "Attack",
+ },
+ interact: {
+ handleTouchInteract,
+ hasTouchInteract: true,
+ touchInteractPrompt: "Open Door",
+ },
+};
+
+const resetTouchActionState = () => {
+ mockTouchActions.attack.hasTouchAttack = true;
+ mockTouchActions.attack.touchAttackPrompt = "Attack";
+ mockTouchActions.interact.hasTouchInteract = true;
+ mockTouchActions.interact.touchInteractPrompt = "Open Door";
+ mockIsTabletLayout = true;
+};
+
vi.mock("@/pages/game/model", () => ({
useGamePageMobileActionPanelModel: () => ({
audioToggle: {
handleAudioMuteToggle,
isAudioMuted: false,
- isTabletLayout: true,
+ isTabletLayout: mockIsTabletLayout,
},
leaderboardTrigger: {
- isTabletLayout: true,
+ isTabletLayout: mockIsTabletLayout,
},
settingsTrigger: {
- isTabletLayout: true,
+ isTabletLayout: mockIsTabletLayout,
},
sheetTrigger: {
handleMobileSheetOpen: vi.fn(),
isMobileSheetOpen: true,
- isTabletLayout: true,
- },
- touchActions: {
- attack: {
- handleTouchAttack,
- hasTouchAttack: true,
- touchAttackPrompt: "Attack",
- },
- interact: {
- handleTouchInteract,
- hasTouchInteract: true,
- touchInteractPrompt: "Open Door",
- },
+ isTabletLayout: mockIsTabletLayout,
},
+ touchActions: mockTouchActions,
}),
}));
@@ -66,53 +86,146 @@ vi.mock("@/widgets/settings-panel", () => ({
SettingsSheet: ({ children }: { children: ReactNode }) => <>{children}>,
}));
+afterEach(() => {
+ handleTouchAttack.mockClear();
+ handleTouchInteract.mockClear();
+ handleAudioMuteToggle.mockClear();
+ resetTouchActionState();
+});
+
describe("GamePageMobileActionPanel", () => {
- it("renders grouped mobile action controls", () => {
+ it("renders the tablet action stack inside a fixed prompt shell", () => {
+ mockIsTabletLayout = true;
+
const { container } = render(
);
- const panelRoot = container.firstElementChild as HTMLElement;
+ const view = within(container);
+ const panelRoot = view.getByTestId(
+ GAME_PAGE_MOBILE_ACTION_PANEL_TEST_IDS.ROOT,
+ );
+ const promptSlot = view.getByTestId(
+ GAME_PAGE_MOBILE_ACTION_PANEL_TEST_IDS.PROMPT_SLOT,
+ );
+ const controlStack = view.getByTestId(
+ GAME_PAGE_MOBILE_ACTION_PANEL_TEST_IDS.CONTROL_STACK,
+ );
+ const touchActionPanel = view.getByTestId(
+ GAME_PAGE_MOBILE_TOUCH_ACTION_PANEL_TEST_IDS.ROOT,
+ );
- expect(screen.getByRole("button", { name: "Attack" })).toBeTruthy();
- expect(screen.getByRole("button", { name: "Open Door" })).toBeTruthy();
- expect(screen.getByRole("button", { name: "Mute audio" })).toBeTruthy();
- expect(
- screen.getByRole("button", { name: "Open Leaderboard" }),
- ).toBeTruthy();
- expect(screen.getByRole("button", { name: "Open Panels" })).toBeTruthy();
- expect(screen.getByRole("button", { name: "Open Settings" })).toBeTruthy();
+ expect(panelRoot.className).toContain("pointer-events-auto");
+ expect(panelRoot.className).toContain("relative");
+ expect(panelRoot.className).toContain(
+ GAME_PAGE_MOBILE_ACTION_PANEL_CLASS_NAMES.TABLET_WIDTH,
+ );
+ expect(panelRoot.className).not.toContain("absolute");
+ expect(promptSlot.className).toBe(
+ GAME_PAGE_MOBILE_ACTION_PANEL_CLASS_NAMES.PROMPT_SLOT,
+ );
+ expect(controlStack.className).toBe(
+ GAME_PAGE_MOBILE_ACTION_PANEL_CLASS_NAMES.CONTROL_STACK,
+ );
+ expect(touchActionPanel.parentElement).toBe(promptSlot);
+ expect(touchActionPanel.className).toBe(
+ GAME_PAGE_MOBILE_TOUCH_ACTION_PANEL_CLASS_NAMES.ROOT,
+ );
+ expect(view.getByRole("button", { name: "Attack" })).toBeTruthy();
+ expect(view.getByRole("button", { name: "Open Door" })).toBeTruthy();
+ expect(view.getByRole("button", { name: "Mute audio" })).toBeTruthy();
+ expect(view.getByRole("button", { name: "Open Leaderboard" })).toBeTruthy();
+ expect(view.getByRole("button", { name: "Open Panels" })).toBeTruthy();
+ expect(view.getByRole("button", { name: "Open Settings" })).toBeTruthy();
expect(screen.getByText("Audio")).toBeTruthy();
expect(screen.getByText("Rankings")).toBeTruthy();
expect(screen.getByText("Panels")).toBeTruthy();
expect(screen.getByText("Settings")).toBeTruthy();
- expect(panelRoot.classList.contains("pointer-events-auto")).toBe(true);
- expect(panelRoot.classList.contains("absolute")).toBe(false);
- expect(panelRoot.className).toContain("w-fit");
expect(panelRoot.getAttribute("data-input-blocks-look")).toBe("true");
+ });
+
+ it("renders the compact action stack without labels on smaller phones", () => {
+ mockIsTabletLayout = false;
+
+ const { container } = render(
);
+ const view = within(container);
+ const panelRoot = view.getByTestId(
+ GAME_PAGE_MOBILE_ACTION_PANEL_TEST_IDS.ROOT,
+ );
+ const promptSlot = view.getByTestId(
+ GAME_PAGE_MOBILE_ACTION_PANEL_TEST_IDS.PROMPT_SLOT,
+ );
+ const touchActionPanel = view.getByTestId(
+ GAME_PAGE_MOBILE_TOUCH_ACTION_PANEL_TEST_IDS.ROOT,
+ );
+
+ expect(panelRoot.className).toContain(
+ GAME_PAGE_MOBILE_ACTION_PANEL_CLASS_NAMES.COMPACT_WIDTH,
+ );
+ expect(panelRoot.className).not.toContain(
+ GAME_PAGE_MOBILE_ACTION_PANEL_CLASS_NAMES.TABLET_WIDTH,
+ );
+ expect(promptSlot.className).toBe(
+ GAME_PAGE_MOBILE_ACTION_PANEL_CLASS_NAMES.PROMPT_SLOT,
+ );
+ expect(touchActionPanel.parentElement).toBe(promptSlot);
+ expect(view.getByRole("button", { name: "Attack" })).toBeTruthy();
+ expect(view.getByRole("button", { name: "Open Door" })).toBeTruthy();
+ expect(view.getByRole("button", { name: "Mute audio" })).toBeTruthy();
+ expect(view.getByRole("button", { name: "Open Leaderboard" })).toBeTruthy();
+ expect(view.getByRole("button", { name: "Open Panels" })).toBeTruthy();
+ expect(view.getByRole("button", { name: "Open Settings" })).toBeTruthy();
+ expect(view.queryByText("Audio")).toBeNull();
+ expect(view.queryByText("Rankings")).toBeNull();
+ expect(view.queryByText("Panels")).toBeNull();
+ expect(view.queryByText("Settings")).toBeNull();
+ });
+
+ it("keeps the prompt slot from changing the panel width when prompts toggle", () => {
+ const { container, rerender } = render(
);
+ const view = within(container);
+ const panelRoot = view.getByTestId(
+ GAME_PAGE_MOBILE_ACTION_PANEL_TEST_IDS.ROOT,
+ );
+ const promptSlot = view.getByTestId(
+ GAME_PAGE_MOBILE_ACTION_PANEL_TEST_IDS.PROMPT_SLOT,
+ );
+ const controlStack = view.getByTestId(
+ GAME_PAGE_MOBILE_ACTION_PANEL_TEST_IDS.CONTROL_STACK,
+ );
+ const touchActionPanel = view.getByTestId(
+ GAME_PAGE_MOBILE_TOUCH_ACTION_PANEL_TEST_IDS.ROOT,
+ );
+
+ const initialPanelClassName = panelRoot.className;
+ const initialPromptSlotClassName = promptSlot.className;
+ const initialControlStackClassName = controlStack.className;
+ const initialTouchActionPanelClassName = touchActionPanel.className;
+ expect(view.getByRole("button", { name: "Attack" })).toBeTruthy();
+ expect(view.getByRole("button", { name: "Open Door" })).toBeTruthy();
+
+ mockTouchActions.attack.hasTouchAttack = false;
+ mockTouchActions.attack.touchAttackPrompt = null;
+ mockTouchActions.interact.hasTouchInteract = false;
+ mockTouchActions.interact.touchInteractPrompt = null;
+
+ rerender(
);
+
+ expect(view.queryByRole("button", { name: "Attack" })).toBeNull();
+ expect(view.queryByRole("button", { name: "Open Door" })).toBeNull();
+ expect(
+ view.getByTestId(GAME_PAGE_MOBILE_ACTION_PANEL_TEST_IDS.ROOT).className,
+ ).toBe(initialPanelClassName);
+ expect(
+ view.getByTestId(GAME_PAGE_MOBILE_ACTION_PANEL_TEST_IDS.PROMPT_SLOT)
+ .className,
+ ).toBe(initialPromptSlotClassName);
+ expect(
+ view.getByTestId(GAME_PAGE_MOBILE_ACTION_PANEL_TEST_IDS.CONTROL_STACK)
+ .className,
+ ).toBe(initialControlStackClassName);
expect(
- screen
- .getAllByRole("button")
- .map(
- (button) =>
- button.getAttribute("aria-label") ??
- button.textContent?.trim() ??
- "",
- ),
- ).toEqual([
- "Open Door",
- "Attack",
- "Mute audio",
- "Open Leaderboard",
- "Open Panels",
- "Open Settings",
- ]);
-
- const settingsIconClass =
- screen
- .getByRole("button", { name: "Open Settings" })
- .querySelector("svg")
- ?.getAttribute("class") ?? "";
-
- expect(settingsIconClass).not.toContain("text-[var(--dungeon-gold)]");
+ view.getByTestId(GAME_PAGE_MOBILE_TOUCH_ACTION_PANEL_TEST_IDS.ROOT)
+ .className,
+ ).toBe(initialTouchActionPanelClassName);
});
it("wires the touch action handlers from the model", () => {
diff --git a/src/pages/game/ui/GamePageMobileActionPanel.tsx b/src/pages/game/ui/GamePageMobileActionPanel.tsx
index 0496c69..fddc1bc 100644
--- a/src/pages/game/ui/GamePageMobileActionPanel.tsx
+++ b/src/pages/game/ui/GamePageMobileActionPanel.tsx
@@ -1,9 +1,14 @@
+import {
+ GAME_PAGE_MOBILE_ACTION_PANEL_CLASS_NAMES,
+ GAME_PAGE_MOBILE_ACTION_PANEL_TEST_IDS,
+} from "@/pages/game/config";
import { useGamePageMobileActionPanelModel } from "@/pages/game/model";
import {
INPUT_POINTER_DATA_ATTRIBUTE_VALUES,
INPUT_POINTER_DATA_ATTRIBUTES,
POINTER_ROLES,
} from "@/shared/config";
+import { cn } from "@/shared/lib/utils";
import { GamePageMobileAudioAction } from "./GamePageMobileAudioAction";
import { GamePageMobileLeaderboardAction } from "./GamePageMobileLeaderboardAction";
@@ -13,25 +18,43 @@ import { GamePageMobileTouchActions } from "./GamePageMobileTouchActions";
export function GamePageMobileActionPanel() {
const viewModel = useGamePageMobileActionPanelModel();
+ const { isTabletLayout } = viewModel.audioToggle;
return (
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/src/pages/game/ui/GamePageMobileCanvasStage.test.tsx b/src/pages/game/ui/GamePageMobileCanvasStage.test.tsx
index 1bbb834..cb6f597 100644
--- a/src/pages/game/ui/GamePageMobileCanvasStage.test.tsx
+++ b/src/pages/game/ui/GamePageMobileCanvasStage.test.tsx
@@ -4,6 +4,10 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CAMERA_MODES } from "@/features/camera-system";
+import {
+ GAME_PAGE_MOBILE_OVERLAY_CLASS_NAMES,
+ GAME_PAGE_MOBILE_OVERLAY_TEST_IDS,
+} from "@/pages/game/config";
const INPUT_STATE_KEYS = {
READY: "ready",
@@ -13,14 +17,25 @@ const INPUT_STATE_KEYS = {
RUN_TOGGLE_OFF: "runToggleOff",
} as const;
+const MOBILE_STAGE_TEST_IDS = {
+ ACTION_PANEL: "game-page-mobile-action-panel",
+ CAMERA_CONTROL_ZONE: "camera-control-zone",
+ GAME_CANVAS: "game-canvas",
+ MOBILE_ACTION_BUTTON_ZONE: "mobile-action-button-zone",
+ TOP_BAR: "game-page-mobile-top-bar",
+ TOUCH_JOYSTICK_ZONE: "touch-joystick-zone",
+} as const;
+
let mockCameraMode: (typeof CAMERA_MODES)[keyof typeof CAMERA_MODES] =
CAMERA_MODES.FREE_ORBITAL;
let mockIsInputBlocked = false;
+let mockTouchPromptsVisible = false;
afterEach(() => {
cleanup();
mockCameraMode = CAMERA_MODES.FREE_ORBITAL;
mockIsInputBlocked = false;
+ mockTouchPromptsVisible = false;
});
vi.mock("@/pages/game/model", () => ({
@@ -66,81 +81,161 @@ vi.mock("@/pages/game/model", () => ({
vi.mock("@/features/input-orchestrator", () => ({
MobileActionButtonZone: ({ isRunEnabled }: { isRunEnabled: boolean }) => (
),
}));
vi.mock("@/features/touch-input", () => ({
- CameraControlZone: () =>
,
- TouchJoystickZone: () =>
,
+ CameraControlZone: () => (
+
+ ),
+ TouchJoystickZone: () => (
+
+ ),
}));
vi.mock("@/widgets/game-canvas", () => ({
- GameCanvas: () =>
,
+ GameCanvas: () =>
,
}));
vi.mock("./GamePageMobileActionPanel", () => ({
GamePageMobileActionPanel: () => (
-
+
),
}));
vi.mock("./GamePageMobileTopBar", () => ({
- GamePageMobileTopBar: () =>
,
+ GamePageMobileTopBar: () => (
+
+ ),
}));
import { GamePageMobileCanvasStage } from "./GamePageMobileCanvasStage";
describe("GamePageMobileCanvasStage", () => {
- it("renders the mobile controls as siblings of the camera control zone", () => {
+ it("renders the mobile controls as sibling columns in the overlay row", () => {
mockCameraMode = CAMERA_MODES.FREE_ORBITAL;
render(
);
+ const overlayRow = screen.getByTestId(
+ GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.ROOT,
+ );
const mobileActionButtonZone = screen.getByTestId(
- "mobile-action-button-zone",
+ MOBILE_STAGE_TEST_IDS.MOBILE_ACTION_BUTTON_ZONE,
+ );
+ const actionPanel = screen.getByTestId(MOBILE_STAGE_TEST_IDS.ACTION_PANEL);
+ const runJumpAnchor = screen.getByTestId(
+ GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.RUN_JUMP_ANCHOR,
+ );
+ const actionPanelAnchor = screen.getByTestId(
+ GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.ACTION_PANEL_ANCHOR,
);
- const actionPanel = screen.getByTestId("game-page-mobile-action-panel");
- expect(screen.getByTestId("game-canvas")).toBeTruthy();
- expect(screen.getByTestId("camera-control-zone")).toBeTruthy();
- expect(screen.getByTestId("touch-joystick-zone")).toBeTruthy();
+ expect(screen.getByTestId(MOBILE_STAGE_TEST_IDS.GAME_CANVAS)).toBeTruthy();
+ expect(
+ screen.getByTestId(MOBILE_STAGE_TEST_IDS.CAMERA_CONTROL_ZONE),
+ ).toBeTruthy();
+ expect(
+ screen.getByTestId(MOBILE_STAGE_TEST_IDS.TOUCH_JOYSTICK_ZONE),
+ ).toBeTruthy();
expect(mobileActionButtonZone).toBeTruthy();
expect(actionPanel).toBeTruthy();
- expect(screen.getByTestId("game-page-mobile-top-bar")).toBeTruthy();
+ expect(screen.getByTestId(MOBILE_STAGE_TEST_IDS.TOP_BAR)).toBeTruthy();
expect(mobileActionButtonZone.getAttribute("data-run-enabled")).toBe(
"false",
);
-
+ expect(overlayRow.className).toBe(
+ GAME_PAGE_MOBILE_OVERLAY_CLASS_NAMES.ROOT,
+ );
+ expect(runJumpAnchor.className).toBe(
+ GAME_PAGE_MOBILE_OVERLAY_CLASS_NAMES.RUN_JUMP_ANCHOR,
+ );
+ expect(actionPanelAnchor.className).toBe(
+ GAME_PAGE_MOBILE_OVERLAY_CLASS_NAMES.ACTION_PANEL_ANCHOR,
+ );
+ expect(runJumpAnchor.parentElement).toBe(overlayRow);
+ expect(actionPanelAnchor.parentElement).toBe(overlayRow);
expect(
screen
- .getByTestId("touch-joystick-zone")
- .closest('[data-testid="camera-control-zone"]'),
+ .getByTestId(MOBILE_STAGE_TEST_IDS.TOUCH_JOYSTICK_ZONE)
+ .closest(
+ `[data-testid="${MOBILE_STAGE_TEST_IDS.CAMERA_CONTROL_ZONE}"]`,
+ ),
).toBeNull();
expect(
- mobileActionButtonZone.closest('[data-testid="camera-control-zone"]'),
+ mobileActionButtonZone.closest(
+ `[data-testid="${MOBILE_STAGE_TEST_IDS.CAMERA_CONTROL_ZONE}"]`,
+ ),
).toBeNull();
expect(
- actionPanel.closest('[data-testid="camera-control-zone"]'),
+ actionPanel.closest(
+ `[data-testid="${MOBILE_STAGE_TEST_IDS.CAMERA_CONTROL_ZONE}"]`,
+ ),
).toBeNull();
expect(
screen
- .getByTestId("game-page-mobile-top-bar")
- .closest('[data-testid="camera-control-zone"]'),
+ .getByTestId(MOBILE_STAGE_TEST_IDS.TOP_BAR)
+ .closest(
+ `[data-testid="${MOBILE_STAGE_TEST_IDS.CAMERA_CONTROL_ZONE}"]`,
+ ),
).toBeNull();
- expect(mobileActionButtonZone.parentElement).toBe(
- actionPanel.parentElement,
+ expect(runJumpAnchor.className.includes("absolute")).toBe(false);
+ expect(actionPanelAnchor.className.includes("absolute")).toBe(false);
+ });
+
+ it("keeps the overlay row stable when touch prompts toggle", () => {
+ const { rerender } = render(
);
+ const overlayRow = screen.getByTestId(
+ GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.ROOT,
+ );
+ const runJumpAnchor = screen.getByTestId(
+ GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.RUN_JUMP_ANCHOR,
+ );
+ const actionPanelAnchor = screen.getByTestId(
+ GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.ACTION_PANEL_ANCHOR,
);
+ const actionPanel = screen.getByTestId(MOBILE_STAGE_TEST_IDS.ACTION_PANEL);
+ const initialOverlayRowClassName = overlayRow.className;
+ const initialRunJumpAnchorClassName = runJumpAnchor.className;
+ const initialActionPanelAnchorClassName = actionPanelAnchor.className;
+
+ expect(actionPanel.getAttribute("data-touch-prompts-visible")).toBe(
+ "false",
+ );
+
+ mockTouchPromptsVisible = true;
+
+ rerender(
);
+
+ expect(
+ screen
+ .getByTestId(MOBILE_STAGE_TEST_IDS.ACTION_PANEL)
+ .getAttribute("data-touch-prompts-visible"),
+ ).toBe("true");
expect(
- mobileActionButtonZone.parentElement?.classList.contains("absolute"),
- ).toBe(true);
+ screen.getByTestId(GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.RUN_JUMP_ANCHOR)
+ .parentElement,
+ ).toBe(screen.getByTestId(GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.ROOT));
expect(
- mobileActionButtonZone.parentElement?.classList.contains("right-4"),
- ).toBe(true);
+ screen.getByTestId(GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.ACTION_PANEL_ANCHOR)
+ .parentElement,
+ ).toBe(screen.getByTestId(GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.ROOT));
expect(
- mobileActionButtonZone.parentElement?.classList.contains("bottom-4"),
- ).toBe(true);
+ screen.getByTestId(GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.ROOT).className,
+ ).toBe(initialOverlayRowClassName);
+ expect(
+ screen.getByTestId(GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.RUN_JUMP_ANCHOR)
+ .className,
+ ).toBe(initialRunJumpAnchorClassName);
+ expect(
+ screen.getByTestId(GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.ACTION_PANEL_ANCHOR)
+ .className,
+ ).toBe(initialActionPanelAnchorClassName);
});
it("keeps the camera surface mounted in first-person mode", () => {
@@ -148,7 +243,9 @@ describe("GamePageMobileCanvasStage", () => {
render(
);
- expect(screen.getByTestId("camera-control-zone")).toBeTruthy();
+ expect(
+ screen.getByTestId(MOBILE_STAGE_TEST_IDS.CAMERA_CONTROL_ZONE),
+ ).toBeTruthy();
});
it("hides the mobile gameplay controls in portrait mode", () => {
@@ -156,12 +253,29 @@ describe("GamePageMobileCanvasStage", () => {
render(
);
- expect(screen.getByTestId("game-canvas")).toBeTruthy();
- expect(screen.queryByTestId("camera-control-zone")).toBeNull();
- expect(screen.queryByTestId("touch-joystick-zone")).toBeNull();
- expect(screen.queryByTestId("mobile-action-button-zone")).toBeNull();
- expect(screen.queryByTestId("game-page-mobile-action-panel")).toBeNull();
- expect(screen.queryByTestId("game-page-mobile-top-bar")).toBeNull();
+ expect(screen.getByTestId(MOBILE_STAGE_TEST_IDS.GAME_CANVAS)).toBeTruthy();
+ expect(
+ screen.queryByTestId(MOBILE_STAGE_TEST_IDS.CAMERA_CONTROL_ZONE),
+ ).toBeNull();
+ expect(
+ screen.queryByTestId(MOBILE_STAGE_TEST_IDS.TOUCH_JOYSTICK_ZONE),
+ ).toBeNull();
+ expect(
+ screen.queryByTestId(MOBILE_STAGE_TEST_IDS.MOBILE_ACTION_BUTTON_ZONE),
+ ).toBeNull();
+ expect(screen.queryByTestId(MOBILE_STAGE_TEST_IDS.ACTION_PANEL)).toBeNull();
+ expect(screen.queryByTestId(MOBILE_STAGE_TEST_IDS.TOP_BAR)).toBeNull();
+ expect(
+ screen.queryByTestId(GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.RUN_JUMP_ANCHOR),
+ ).toBeNull();
+ expect(
+ screen.queryByTestId(
+ GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.ACTION_PANEL_ANCHOR,
+ ),
+ ).toBeNull();
+ expect(
+ screen.queryByTestId(GAME_PAGE_MOBILE_OVERLAY_TEST_IDS.ROOT),
+ ).toBeNull();
});
it("suppresses the mobile gameplay context menu", () => {
diff --git a/src/pages/game/ui/GamePageMobileCanvasStage.tsx b/src/pages/game/ui/GamePageMobileCanvasStage.tsx
index 465af0f..8ea5c72 100644
--- a/src/pages/game/ui/GamePageMobileCanvasStage.tsx
+++ b/src/pages/game/ui/GamePageMobileCanvasStage.tsx
@@ -1,5 +1,9 @@
import { MobileActionButtonZone } from "@/features/input-orchestrator";
import { CameraControlZone, TouchJoystickZone } from "@/features/touch-input";
+import {
+ GAME_PAGE_MOBILE_OVERLAY_CLASS_NAMES,
+ GAME_PAGE_MOBILE_OVERLAY_TEST_IDS,
+} from "@/pages/game/config";
import {
useGamePageCameraElements,
useGamePageInputContext,
@@ -42,8 +46,12 @@ export function GamePageMobileCanvasStage() {
-
-
+
+
-
-
+
>
) : null}
diff --git a/src/pages/game/ui/GamePageMobileTopBar.test.tsx b/src/pages/game/ui/GamePageMobileTopBar.test.tsx
new file mode 100644
index 0000000..da005c0
--- /dev/null
+++ b/src/pages/game/ui/GamePageMobileTopBar.test.tsx
@@ -0,0 +1,81 @@
+// @vitest-environment happy-dom
+
+import { render, screen, within } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ GAME_PAGE_CONTROLS,
+ GAME_PAGE_HOME_ACTION_TEST_IDS,
+ GAME_PAGE_MOBILE_TOP_BAR_TEST_IDS,
+} from "@/pages/game/config";
+
+import { GamePageMobileTopBar } from "./GamePageMobileTopBar";
+
+vi.mock("@/pages/game/model", () => ({
+ useGamePageMobileTopBarModel: () => ({
+ cameraStateSnapshot: {
+ mode: "free-orbital",
+ },
+ handleCameraModeSwitch: vi.fn(),
+ handleDungeonRunReset: vi.fn(),
+ playerHp: 42,
+ playerMaxHp: 100,
+ }),
+}));
+
+vi.mock("@/widgets/camera-mode-switcher", () => ({
+ MobileCameraModeSwitcher: () =>
,
+}));
+
+vi.mock("@tanstack/react-router", () => ({
+ Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
+
+ {children}
+
+ ),
+}));
+
+describe("GamePageMobileTopBar", () => {
+ it("places the home action to the right of the HP panel", () => {
+ render(
);
+
+ const root = screen.getByTestId(GAME_PAGE_MOBILE_TOP_BAR_TEST_IDS.ROOT);
+ const leftCluster = screen.getByTestId(
+ GAME_PAGE_MOBILE_TOP_BAR_TEST_IDS.LEFT_CLUSTER,
+ );
+ const rightCluster = screen.getByTestId(
+ GAME_PAGE_MOBILE_TOP_BAR_TEST_IDS.RIGHT_CLUSTER,
+ );
+ const hpPanel = screen.getByTestId(
+ GAME_PAGE_MOBILE_TOP_BAR_TEST_IDS.HP_PANEL,
+ );
+ const homeAction = screen.getByTestId(GAME_PAGE_HOME_ACTION_TEST_IDS.ROOT);
+
+ expect(root.className).toContain("justify-between");
+ expect(root.className).toContain("items-start");
+ expect(leftCluster.parentElement).toBe(root);
+ expect(rightCluster.parentElement).toBe(root);
+ expect(hpPanel.parentElement).toBe(rightCluster);
+ expect(homeAction.parentElement).toBe(rightCluster);
+ expect(rightCluster.firstElementChild).toBe(hpPanel);
+ expect(rightCluster.lastElementChild).toBe(homeAction);
+ expect(homeAction.getAttribute("href")).toBe("/");
+ expect(homeAction.getAttribute("data-size")).toBe("icon-sm");
+ expect(homeAction.getAttribute("data-variant")).toBe("dungeon-outline");
+ expect(homeAction.className).not.toContain("w-fit");
+ expect(
+ screen.getByRole("button", {
+ name: "Restart Run",
+ }),
+ ).toBeTruthy();
+ expect(
+ screen.getByRole("link", {
+ name: GAME_PAGE_CONTROLS.NAVIGATION.HOME_ARIA_LABEL,
+ }),
+ ).toBe(homeAction);
+ expect(within(root).getByTestId("mobile-camera-switcher")).toBeTruthy();
+ expect(screen.getByText("Camera")).toBeTruthy();
+ expect(screen.getByText("HP")).toBeTruthy();
+ });
+});
diff --git a/src/pages/game/ui/GamePageMobileTopBar.tsx b/src/pages/game/ui/GamePageMobileTopBar.tsx
index 770d25e..fe01ef7 100644
--- a/src/pages/game/ui/GamePageMobileTopBar.tsx
+++ b/src/pages/game/ui/GamePageMobileTopBar.tsx
@@ -1,30 +1,24 @@
-import { Link } from "@tanstack/react-router";
-import { Home, RotateCcw } from "lucide-react";
+import { RotateCcw } from "lucide-react";
-import { GAME_PAGE_CONTROLS } from "@/pages/game/config";
+import { GAME_PAGE_MOBILE_TOP_BAR_TEST_IDS } from "@/pages/game/config";
import { useGamePageMobileTopBarModel } from "@/pages/game/model";
import { Button } from "@/shared/ui";
import { MobileCameraModeSwitcher } from "@/widgets/camera-mode-switcher";
-import { MARKETING_ROUTES } from "@/widgets/marketing-shell";
+
+import { GamePageHomeAction } from "./GamePageHomeAction";
export function GamePageMobileTopBar() {
const viewModel = useGamePageMobileTopBarModel();
return (
-
-
-
-
+
+
Camera
@@ -46,15 +40,25 @@ export function GamePageMobileTopBar() {
-
-
-
- HP
-
-
- {viewModel.playerHp} / {viewModel.playerMaxHp}
-
+
+
+
+
+ HP
+
+
+ {viewModel.playerHp} / {viewModel.playerMaxHp}
+
+
+
+
);
diff --git a/src/pages/game/ui/GamePageMobileTouchActions.tsx b/src/pages/game/ui/GamePageMobileTouchActions.tsx
index 3e631d6..4a8e909 100644
--- a/src/pages/game/ui/GamePageMobileTouchActions.tsx
+++ b/src/pages/game/ui/GamePageMobileTouchActions.tsx
@@ -1,5 +1,8 @@
+import {
+ GAME_PAGE_MOBILE_TOUCH_ACTION_PANEL_CLASS_NAMES,
+ GAME_PAGE_MOBILE_TOUCH_ACTION_PANEL_TEST_IDS,
+} from "@/pages/game/config";
import { Badge, Button } from "@/shared/ui";
-
import type { GamePageMobileActionPanelModel } from "../model";
import { useMobileActionPointerGuards } from "../model/useMobileActionPointerGuards";
@@ -14,17 +17,30 @@ export function GamePageMobileTouchActions({
const { stopActionPointerPropagation } = useMobileActionPointerGuards();
return (
- <>
+
{interact.hasTouchInteract ? (
) : null}
@@ -34,12 +50,22 @@ export function GamePageMobileTouchActions({
size="default"
onClick={attack.handleTouchAttack}
onPointerDown={stopActionPointerPropagation}
- className="pointer-events-auto relative w-full font-bold"
+ className={GAME_PAGE_MOBILE_TOUCH_ACTION_PANEL_CLASS_NAMES.BUTTON}
>
- {attack.touchAttackPrompt}
-
+
+ {attack.touchAttackPrompt}
+
+
) : null}
- >
+
);
}
diff --git a/src/pages/home/ui/HomeBootstrapAuthenticatedStatusCard.tsx b/src/pages/home/ui/HomeBootstrapAuthenticatedStatusCard.tsx
index 5529973..948ce20 100644
--- a/src/pages/home/ui/HomeBootstrapAuthenticatedStatusCard.tsx
+++ b/src/pages/home/ui/HomeBootstrapAuthenticatedStatusCard.tsx
@@ -18,7 +18,7 @@ export function HomeBootstrapAuthenticatedStatusCard({
icon={
}
title={HOME_STATUS_COPY.AUTHENTICATED.title}
>
-
+
{readyStatusLabel ? (
diff --git a/src/pages/home/ui/HomeBootstrapStatusCard.test.tsx b/src/pages/home/ui/HomeBootstrapStatusCard.test.tsx
index 1215bf5..1814960 100644
--- a/src/pages/home/ui/HomeBootstrapStatusCard.test.tsx
+++ b/src/pages/home/ui/HomeBootstrapStatusCard.test.tsx
@@ -74,5 +74,12 @@ describe("HomeBootstrapStatusCard", () => {
expect(
screen.getByText(HOME_STATUS_COPY.AUTHENTICATED.badge),
).not.toBeNull();
+
+ const authenticatedDetailRow = screen
+ .getByText(HOME_STATUS_COPY.AUTHENTICATED.detail)
+ .closest("div");
+
+ expect(authenticatedDetailRow?.className).toContain("justify-center");
+ expect(authenticatedDetailRow?.className).toContain("text-center");
});
});
diff --git a/src/pages/home/ui/HomeHeroSection.tsx b/src/pages/home/ui/HomeHeroSection.tsx
index de82989..16c17c9 100644
--- a/src/pages/home/ui/HomeHeroSection.tsx
+++ b/src/pages/home/ui/HomeHeroSection.tsx
@@ -72,7 +72,7 @@ export function HomeHeroSection({
readyStatusLabel={readyStatusLabel}
/>
-
+
{HOME_COPY.MOBILE_ORIENTATION_NOTICE}
diff --git a/src/pages/home/ui/HomePage.test.tsx b/src/pages/home/ui/HomePage.test.tsx
index b2340d9..bcf8f73 100644
--- a/src/pages/home/ui/HomePage.test.tsx
+++ b/src/pages/home/ui/HomePage.test.tsx
@@ -86,6 +86,13 @@ describe("HomePage", () => {
).getAllByRole("listitem")[0];
expect(firstTeachingCard?.className).toContain("lg:col-span-2");
expect(screen.queryByText(HOME_COPY.RUNTIME_HEADING)).toBeNull();
+
+ const mobileOrientationNotice = screen
+ .getByText(HOME_COPY.MOBILE_ORIENTATION_NOTICE)
+ .closest("div");
+
+ expect(mobileOrientationNotice?.className).toContain("text-center");
+ expect(mobileOrientationNotice?.className).toContain("sm:hidden");
});
it("requests username entry before authentication", () => {