diff --git a/src/app/routes/concepts.tsx b/src/app/routes/concepts.tsx new file mode 100644 index 00000000..ff44ac71 --- /dev/null +++ b/src/app/routes/concepts.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ConceptsPage } from "@/pages/concepts"; + +export const Route = createFileRoute("/concepts")({ + component: ConceptsPage, +}); diff --git a/src/pages/concepts/config/conceptsCopy.ts b/src/pages/concepts/config/conceptsCopy.ts new file mode 100644 index 00000000..19c16e64 --- /dev/null +++ b/src/pages/concepts/config/conceptsCopy.ts @@ -0,0 +1,41 @@ +export const CONCEPTS_COPY = { + CTA_LABEL: "Enter Dungeon", + HEADING: "System concepts.", + SECONDARY_LINK_LABEL: "Read the Guide", + SUBTITLE: + "Runestone maps statechart ideas to dungeon objects so software behavior can be explored spatially.", + CTA_HEADING: "Ready to inspect the dungeon?", +} as const; + +export const CONCEPTS_SECTIONS = [ + { + detail: + "A state describes where the system currently is. In Runestone, that state is represented by the room the player occupies.", + label: "State → Room", + }, + { + detail: + "A transition describes how the system moves from one state to another. In the dungeon, transitions appear as corridors and doorways.", + label: "Transition → Corridor", + }, + { + detail: + "Events are messages sent to the system. Movement, interaction, attack, run, and jump actions all dispatch events.", + label: "Event → Input or prompt", + }, + { + detail: + "A guard decides whether a transition is allowed. Locked doors, keys, enemies, and room conditions make those rules visible.", + label: "Guard → Locked door", + }, + { + detail: + "Context is the data carried by the run. Keys, HP, current room, and discovered rooms can change what paths are available.", + label: "Context → Inventory and HP", + }, + { + detail: + "Actors isolate behavior. Camera, player, input, audio, and dungeon logic can each respond to events without becoming one tangled system.", + label: "Actor → Independent loop", + }, +] as const; diff --git a/src/pages/concepts/config/index.ts b/src/pages/concepts/config/index.ts new file mode 100644 index 00000000..0c152f32 --- /dev/null +++ b/src/pages/concepts/config/index.ts @@ -0,0 +1 @@ +export { CONCEPTS_COPY, CONCEPTS_SECTIONS } from "./conceptsCopy"; diff --git a/src/pages/concepts/index.ts b/src/pages/concepts/index.ts new file mode 100644 index 00000000..9b671281 --- /dev/null +++ b/src/pages/concepts/index.ts @@ -0,0 +1 @@ +export { ConceptsPage } from "./ui"; diff --git a/src/pages/concepts/ui/ConceptsPage.test.tsx b/src/pages/concepts/ui/ConceptsPage.test.tsx new file mode 100644 index 00000000..b518c80f --- /dev/null +++ b/src/pages/concepts/ui/ConceptsPage.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ConceptsPage } from "./ConceptsPage"; + +const mockUseAuthContext = vi.hoisted(() => vi.fn()); + +vi.mock("@/features/auth", async () => { + const actual = + await vi.importActual("@/features/auth"); + + return { + ...actual, + useAuthContext: mockUseAuthContext, + }; +}); + +vi.mock("@tanstack/react-router", () => ({ + Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => ( + + {children} + + ), +})); + +afterEach(cleanup); + +beforeEach(() => { + mockUseAuthContext.mockReturnValue({ + isAuthenticated: true, + }); +}); + +describe("ConceptsPage", () => { + it("renders the standalone concepts page without tabs", () => { + render(); + + expect(screen.getByText("System concepts.")).not.toBeNull(); + expect(screen.getByText("State → Room")).not.toBeNull(); + expect(screen.getByText("Actor → Independent loop")).not.toBeNull(); + expect(screen.queryByRole("tab", { name: "Controls" })).toBeNull(); + expect(screen.queryByRole("tab", { name: "First Run" })).toBeNull(); + expect(screen.queryByRole("tab", { name: "Concepts" })).toBeNull(); + }); + + it("includes the GitHub footer link from the shared marketing shell", () => { + render(); + + expect( + screen.getByRole("link", { + name: "GitHub", + }), + ).not.toBeNull(); + }); +}); diff --git a/src/pages/concepts/ui/ConceptsPage.tsx b/src/pages/concepts/ui/ConceptsPage.tsx new file mode 100644 index 00000000..8e1838f3 --- /dev/null +++ b/src/pages/concepts/ui/ConceptsPage.tsx @@ -0,0 +1,86 @@ +import { Link } from "@tanstack/react-router"; +import { ArrowRight, DoorOpen } from "lucide-react"; + +import { useAuthContext } from "@/features/auth"; +import { Button, Card, CardContent } from "@/shared/ui"; +import { + MARKETING_NAVIGATION_ITEM_IDS, + MARKETING_ROUTES, + MarketingShell, +} from "@/widgets/marketing-shell"; + +import { CONCEPTS_COPY, CONCEPTS_SECTIONS } from "../config"; + +export function ConceptsPage() { + const { isAuthenticated } = useAuthContext(); + + return ( + +
+
+

+ {CONCEPTS_COPY.HEADING} +

+

+ {CONCEPTS_COPY.SUBTITLE} +

+
+ +
+ {CONCEPTS_SECTIONS.map((section) => ( + + +
+

+ {section.label} +

+ +
+

+ {section.detail} +

+
+
+ ))} +
+ + + +

+ {CONCEPTS_COPY.CTA_HEADING} +

+
+ {isAuthenticated ? ( + + ) : ( + + )} + +
+
+
+
+
+ ); +} diff --git a/src/pages/concepts/ui/index.ts b/src/pages/concepts/ui/index.ts new file mode 100644 index 00000000..e1ff9c26 --- /dev/null +++ b/src/pages/concepts/ui/index.ts @@ -0,0 +1 @@ +export { ConceptsPage } from "./ConceptsPage"; diff --git a/src/pages/home/config/homeCopy.ts b/src/pages/home/config/homeCopy.ts index 675b8436..d8478666 100644 --- a/src/pages/home/config/homeCopy.ts +++ b/src/pages/home/config/homeCopy.ts @@ -1,28 +1,99 @@ export const HOME_COPY = { - BADGE: "Dungeon briefing", + BADGE: "Playable architecture", CTA_LABEL: "Enter Dungeon", - FEATURES_HEADING: "What you’ll learn", - HEADING: "Runestone", - SUBTITLE: "A living dungeon where rooms change as you move through them.", - TUTORIAL_LABEL: "How to Play", - SESSION_NOTE: - "We’re getting your game ready. You can keep reading the guide while we sign you in.", + FEATURES_HEADING: "What the dungeon teaches", + HEADING: "Walk through executable logic.", + MANIFEST_PATH_HEADING: "Manifest Path", + MANIFEST_PATH_SUBTITLE: "Follow the sequence. Decode the statechart.", + MOBILE_ORIENTATION_NOTICE: + "Landscape mode is recommended for gameplay and full logic visualization.", + RUNTIME_HEADING: "Read the system while you play.", + RUNTIME_SUBTITLE: + "Gameplay, state, and context are shown together so the dungeon can be read as a running statechart.", + SUBTITLE: + "Runestone turns statecharts into a 3D dungeon: rooms are states, corridors are transitions, and every action is driven by explicit events.", + TUTORIAL_LABEL: "Read the Guide", } as const; +export const HOME_MANIFEST_TONE_CLASS_NAMES = { + active: "border-primary/50 bg-primary/10 text-primary", + available: "border-accent/50 bg-accent/10 text-accent", + sealed: + "border-dungeon-rune-sealed/50 bg-dungeon-rune-sealed/10 text-dungeon-rune-sealed", +} as const; + +export const HOME_MANIFEST_PATH = [ + { + detail: "Initialize run. Establish current room.", + label: "Entrance", + tone: "active", + }, + { + detail: "Move through a valid transition.", + label: "Corridor", + tone: "available", + }, + { + detail: "Guard condition blocks progress.", + label: "Locked Door", + tone: "sealed", + }, + { + detail: "Use context to unlock the path.", + label: "Inventory Check", + tone: "available", + }, + { + detail: "Camera, input, and dungeon logic process events independently.", + label: "Actor Loops", + tone: "active", + }, +] as const; + +export const HOME_TRANSLATION_RAIL = [ + "State → Room", + "Transition → Corridor", + "Guard → Locked Door", + "Context → Inventory", + "Actor → Loop", +] as const; + +export const HOME_RUNTIME_PANELS = [ + { + detail: "The playable dungeon frame anchors the learning experience.", + label: "Viewport", + }, + { + detail: "The active room maps to the current state.", + label: "Statechart", + }, + { + detail: "Context explains why paths open or remain locked.", + label: "Inspector", + }, +] as const; + export const HOME_FEATURES = [ { - detail: "Rooms and doors react as you move forward.", - title: "The dungeon shifts as you progress.", + detail: "Each chamber shows where the system currently is.", + title: "States become rooms", }, { - detail: - "Switch between third-person, top-down, first-person, and free-orbital views.", - title: "Pick the view that feels easiest to play.", + detail: "Input, prompts, and combat dispatch explicit events.", + title: "Events move the system", + }, + { + detail: "Doors open only when context satisfies the transition rule.", + title: "Guards control progression", }, { detail: - "The guide covers movement, interaction, attack, and camera controls.", - title: "Learn the basics before you descend.", + "Camera, player, input, audio, and dungeon logic run through focused loops.", + title: "Actors stay isolated", + }, + { + detail: "Keys, HP, and current room data shape what can happen next.", + title: "Context changes valid paths", }, ] as const; diff --git a/src/pages/home/config/index.ts b/src/pages/home/config/index.ts index c7a4e12c..011ea0cc 100644 --- a/src/pages/home/config/index.ts +++ b/src/pages/home/config/index.ts @@ -1 +1,9 @@ -export { HOME_COPY, HOME_FEATURES, HOME_STATUS_COPY } from "./homeCopy"; +export { + HOME_COPY, + HOME_FEATURES, + HOME_MANIFEST_PATH, + HOME_MANIFEST_TONE_CLASS_NAMES, + HOME_RUNTIME_PANELS, + HOME_STATUS_COPY, + HOME_TRANSLATION_RAIL, +} from "./homeCopy"; diff --git a/src/pages/home/ui/HomePage.test.tsx b/src/pages/home/ui/HomePage.test.tsx index ee2c1e46..6e82bfd3 100644 --- a/src/pages/home/ui/HomePage.test.tsx +++ b/src/pages/home/ui/HomePage.test.tsx @@ -37,144 +37,88 @@ beforeEach(() => { mockUseAuthContext.mockReset(); }); +const createAuthContext = (isAuthenticated: boolean) => ({ + authStatus: isAuthenticated + ? AUTH_STATUS.AUTHENTICATED + : AUTH_STATUS.REQUIRES_USERNAME, + errorMessage: null, + handleSessionBootstrapRetry: vi.fn(), + handleUsernameFormSubmit: vi.fn(), + isAuthenticated, + isCheckingSession: false, + isUsernameModalOpen: false, + isUsernameSubmitting: false, + readyStatusLabel: isAuthenticated ? "Rune_AshBearAAAA" : null, + suggestedUsername: "Rune_AshBearAAAA", +}); + describe("HomePage", () => { - it.each([ - { - isCheckingSession: true, - label: "auth checking", - authStatus: AUTH_STATUS.CHECKING_SESSION, - statusHeading: HOME_STATUS_COPY.CHECKING_SESSION.title, - }, - { - isCheckingSession: false, - label: "unauthenticated", - authStatus: AUTH_STATUS.REQUIRES_USERNAME, - statusHeading: HOME_STATUS_COPY.REQUIRES_USERNAME.title, - }, - { - isCheckingSession: false, - label: "bootstrap failure", - authStatus: AUTH_STATUS.BOOTSTRAP_FAILED, - statusHeading: HOME_STATUS_COPY.BOOTSTRAP_FAILED.title, - }, - ])("keeps dungeon entry disabled while $label", ({ - authStatus, - isCheckingSession, - statusHeading, - }) => { - mockUseAuthContext.mockReturnValue({ - authStatus, - errorMessage: null, - handleUsernameFormSubmit: vi.fn(), - handleSessionBootstrapRetry: vi.fn(), - isAuthenticated: false, - isCheckingSession, - isUsernameModalOpen: false, - isUsernameSubmitting: false, - readyStatusLabel: null, - suggestedUsername: "Rune_AshBearAAAA", - }); + it("renders the redesigned landing copy and concept sections", () => { + mockUseAuthContext.mockReturnValue(createAuthContext(false)); render(); - const dungeonEntryButton = screen.getByRole("button", { - name: HOME_COPY.CTA_LABEL, - }); - - expect((dungeonEntryButton as HTMLButtonElement).disabled).toBe(true); - expect(dungeonEntryButton.getAttribute("data-variant")).toBe("default"); expect( - screen.getByRole("link", { - name: HOME_COPY.TUTORIAL_LABEL, - }), + screen.getByRole("heading", { name: HOME_COPY.HEADING }), ).not.toBeNull(); - expect( - screen - .getByRole("link", { - name: HOME_COPY.TUTORIAL_LABEL, - }) - .getAttribute("data-variant"), - ).toBe("outline"); expect(screen.getByText(HOME_COPY.SUBTITLE)).not.toBeNull(); - expect(screen.getByText(HOME_COPY.SESSION_NOTE)).not.toBeNull(); - expect(screen.getByText(statusHeading)).not.toBeNull(); - - const main = screen.getByRole("main"); - - expect(main.className).toContain("h-dvh"); - expect(main.className).toContain("overflow-y-auto"); - expect(main.className).toContain("overscroll-contain"); + expect(screen.getByText(HOME_COPY.MANIFEST_PATH_SUBTITLE)).not.toBeNull(); + expect(screen.getByText(HOME_COPY.RUNTIME_HEADING)).not.toBeNull(); + expect(screen.getByText(HOME_COPY.FEATURES_HEADING)).not.toBeNull(); + expect(screen.getByText("State → Room")).not.toBeNull(); + expect(screen.getByText("States become rooms")).not.toBeNull(); + expect(screen.getByText("Viewport")).not.toBeNull(); }); - it("shows the dungeon entry CTA after authentication", () => { - mockUseAuthContext.mockReturnValue({ - authStatus: AUTH_STATUS.AUTHENTICATED, - errorMessage: null, - handleUsernameFormSubmit: vi.fn(), - handleSessionBootstrapRetry: vi.fn(), - isAuthenticated: true, - isCheckingSession: false, - isUsernameModalOpen: false, - isUsernameSubmitting: false, - readyStatusLabel: "rune-scribe#42", - suggestedUsername: "Rune_AshBearAAAA", - }); + it("keeps dungeon entry disabled before authentication", () => { + mockUseAuthContext.mockReturnValue(createAuthContext(false)); render(); - expect( - screen.getByRole("link", { - name: HOME_COPY.CTA_LABEL, - }), - ).not.toBeNull(); expect( screen - .getByRole("link", { + .getAllByRole("button", { name: HOME_COPY.CTA_LABEL, }) - .getAttribute("data-variant"), - ).toBe("default"); + .every((button) => (button as HTMLButtonElement).disabled), + ).toBe(true); expect( - screen - .getByRole("link", { - name: HOME_COPY.TUTORIAL_LABEL, - }) - .getAttribute("data-variant"), - ).toBe("outline"); - expect(screen.getByText(HOME_COPY.BADGE)).not.toBeNull(); - expect(screen.getByText(HOME_COPY.SESSION_NOTE)).not.toBeNull(); - expect(screen.getByText(HOME_COPY.FEATURES_HEADING)).not.toBeNull(); + screen.getByRole("link", { + name: HOME_COPY.TUTORIAL_LABEL, + }), + ).not.toBeNull(); + }); + + it("shows dungeon entry links after authentication", () => { + mockUseAuthContext.mockReturnValue(createAuthContext(true)); + + render(); + + expect( + screen.getAllByRole("link", { + name: HOME_COPY.CTA_LABEL, + }).length, + ).toBeGreaterThan(0); }); it("shows a retry action when bootstrap fails", () => { const handleSessionBootstrapRetry = vi.fn(); mockUseAuthContext.mockReturnValue({ + ...createAuthContext(false), authStatus: AUTH_STATUS.BOOTSTRAP_FAILED, - authenticatedProfile: null, errorMessage: "Convex unreachable", - handleUsernameFormSubmit: vi.fn(), handleSessionBootstrapRetry, - isAuthenticated: false, - isCheckingSession: false, - isUsernameModalOpen: false, - isUsernameSubmitting: false, - readyStatusLabel: null, - suggestedUsername: "Rune_AshBearAAAA", }); render(); - expect( - screen.getByRole("button", { - name: HOME_STATUS_COPY.BOOTSTRAP_FAILED.actionLabel, - }), - ).not.toBeNull(); screen .getByRole("button", { name: HOME_STATUS_COPY.BOOTSTRAP_FAILED.actionLabel, }) .click(); + expect(handleSessionBootstrapRetry).toHaveBeenCalledOnce(); }); }); diff --git a/src/pages/home/ui/HomePage.tsx b/src/pages/home/ui/HomePage.tsx index e933b870..e39b3b80 100644 --- a/src/pages/home/ui/HomePage.tsx +++ b/src/pages/home/ui/HomePage.tsx @@ -1,14 +1,18 @@ import { Link } from "@tanstack/react-router"; -import { BookOpenText, DoorOpen, Sparkles } from "lucide-react"; +import { ArrowRight, DoorOpen, Key } from "lucide-react"; -import { - AUTH_ROUTE_PATHS, - UsernameModal, - useAuthContext, -} from "@/features/auth"; -import { Badge, Button, Card } from "@/shared/ui"; +import { UsernameModal, useAuthContext } from "@/features/auth"; +import { Badge, Button, Card, CardContent, Separator } from "@/shared/ui"; +import { MARKETING_ROUTES, MarketingShell } from "@/widgets/marketing-shell"; -import { HOME_COPY, HOME_FEATURES } from "../config"; +import { + HOME_COPY, + HOME_FEATURES, + HOME_MANIFEST_PATH, + HOME_MANIFEST_TONE_CLASS_NAMES, + HOME_RUNTIME_PANELS, + HOME_TRANSLATION_RAIL, +} from "../config"; import { HomeBootstrapStatusCard } from "./HomeBootstrapStatusCard"; export function HomePage() { @@ -26,105 +30,219 @@ export function HomePage() { return ( <> -
-
- -
-
-
+
+
+ + +
- {HOME_COPY.BADGE} -
-
-

- {HOME_COPY.HEADING} -

-

- {HOME_COPY.SUBTITLE} -

-

- {HOME_COPY.SESSION_NOTE} -

+
+

+ {HOME_COPY.HEADING} +

+

+ {HOME_COPY.SUBTITLE} +

+
{isAuthenticated ? ( ) : ( )} -
-
- - - -
-
-
+ +
+

+ {HOME_COPY.MANIFEST_PATH_HEADING} +

+
    + {HOME_TRANSLATION_RAIL.map((item) => ( +
  • + + + + {item} + + + + +
  • + ))} +
+
+ +
+
+

+ {HOME_COPY.RUNTIME_HEADING} +

+

+ {HOME_COPY.RUNTIME_SUBTITLE} +

+
+ +
    + {HOME_RUNTIME_PANELS.map((panel) => ( +
  • + + +
    + +
    -

    - {title} +

    + {panel.label} +

    +

    + {panel.detail}

    +
    +
    +
    +
  • + ))} +
+
+ + + +
+
+ +

+ {HOME_COPY.FEATURES_HEADING} +

+
+ +
    + {HOME_FEATURES.map(({ detail, title }) => ( +
  • + + +
    + +
    +
    +

    + {title} +

    {detail}

    -
  • - ))} -
-
-
-
+ + + + ))} + +
-
+ + -
-
- -
-
-

- {TUTORIAL_COPY.CONTROLS_HEADING} -

-

- {TUTORIAL_COPY.CONTROLS_DESCRIPTION} -

-
-
- -
- {TUTORIAL_CONTROLS.map(({ detail, keyLabel, label }) => ( -
-
-
{label}
-
{detail}
-
- - {keyLabel} - -
- ))} -
- - ); -} diff --git a/src/pages/tutorial/ui/TutorialObjectivesCard.tsx b/src/pages/tutorial/ui/TutorialObjectivesCard.tsx deleted file mode 100644 index 8d9014ec..00000000 --- a/src/pages/tutorial/ui/TutorialObjectivesCard.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Target } from "lucide-react"; - -import { Badge } from "@/shared/ui"; - -import { TUTORIAL_COPY, TUTORIAL_OBJECTIVES } from "../config"; - -export function TutorialObjectivesCard() { - return ( -
-
-
- -
-
-

- {TUTORIAL_COPY.OBJECTIVES_HEADING} -

-

- {TUTORIAL_COPY.OBJECTIVES_DESCRIPTION} -

-
-
- -
    - {TUTORIAL_OBJECTIVES.map(({ step, label, detail }) => ( -
  1. - - {step} - -
    -

    {label}

    -

    {detail}

    -
    -
  2. - ))} -
-
- ); -} diff --git a/src/pages/tutorial/ui/TutorialPage.test.tsx b/src/pages/tutorial/ui/TutorialPage.test.tsx index 636495f2..d918e1c2 100644 --- a/src/pages/tutorial/ui/TutorialPage.test.tsx +++ b/src/pages/tutorial/ui/TutorialPage.test.tsx @@ -1,13 +1,16 @@ // @vitest-environment happy-dom -import { cleanup, render, screen, within } from "@testing-library/react"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { AUTH_STATUS } from "@/features/auth"; - -import { TUTORIAL_CONTROLS, TUTORIAL_COPY } from "../config"; - import { TutorialPage } from "./TutorialPage"; const mockUseAuthContext = vi.hoisted(() => vi.fn()); @@ -33,113 +36,85 @@ vi.mock("@tanstack/react-router", () => ({ afterEach(cleanup); beforeEach(() => { - mockUseAuthContext.mockReset(); + mockUseAuthContext.mockReturnValue({ + isAuthenticated: true, + }); }); +const activateTab = (name: string) => { + const tab = screen.getByRole("tab", { name }); + + fireEvent.pointerDown(tab); + fireEvent.mouseDown(tab); + fireEvent.click(tab); +}; + describe("TutorialPage", () => { - it.each([ - { - authStatus: AUTH_STATUS.CHECKING_SESSION, - isCheckingSession: true, - label: "auth checking", - }, - { - authStatus: AUTH_STATUS.BOOTSTRAP_FAILED, - isCheckingSession: false, - label: "bootstrap failure", - }, - ])("keeps the dungeon entry CTA disabled while $label", ({ - authStatus, - isCheckingSession, - }) => { - mockUseAuthContext.mockReturnValue({ - authenticatedProfile: null, - authStatus, - errorMessage: null, - handleUsernameFormSubmit: vi.fn(), - isAuthenticated: false, - isCheckingSession, - isUsernameModalOpen: false, - isUsernameSubmitting: false, - readyStatusLabel: null, - suggestedUsername: "Rune_AshBearAAAA", - }); + it("renders the guide with stable tabs", () => { + render(); + + expect(screen.getByText("Play the system.")).not.toBeNull(); + expect(screen.getByRole("tab", { name: "Controls" })).not.toBeNull(); + expect(screen.getByRole("tab", { name: "First Run" })).not.toBeNull(); + expect(screen.getByRole("tab", { name: "Concepts" })).not.toBeNull(); + }); + it("renders accurate desktop controls", () => { render(); - const dungeonEntryButton = screen.getByRole("button", { - name: TUTORIAL_COPY.CTA_LABEL, - }); + expect(screen.getByText("WASD / Arrows — Move")).not.toBeNull(); + expect(screen.getByText("Shift — Run toggle")).not.toBeNull(); + expect(screen.getByText("Space — Jump")).not.toBeNull(); + expect(screen.getByText("E — Interact")).not.toBeNull(); + expect(screen.getByText("F — Attack")).not.toBeNull(); + expect(screen.getByText("4 / FO — Free Orbital")).not.toBeNull(); + }); + + it("renders first run content without developer setup copy", async () => { + render(); - expect((dungeonEntryButton as HTMLButtonElement).disabled).toBe(true); - expect(dungeonEntryButton.getAttribute("data-variant")).toBe("default"); + activateTab("First Run"); + + await waitFor(() => + expect(screen.getByText("First run path")).not.toBeNull(), + ); + expect(screen.getByText("Enter the initial state")).not.toBeNull(); + expect(screen.queryByText(/git clone/i)).toBeNull(); + expect(screen.queryByText(/pnpm/i)).toBeNull(); + expect(screen.queryByText(/deployment/i)).toBeNull(); + }); + + it("renders concept mapping content", async () => { + render(); + + activateTab("Concepts"); + + await waitFor(() => + expect(screen.getByText("Concepts in play")).not.toBeNull(), + ); + expect(screen.getByText("State → Room")).not.toBeNull(); expect( - screen.getByRole("link", { - name: TUTORIAL_COPY.HOME_LABEL, - }), + screen.getByText("Actor → Camera, player, input, audio"), ).not.toBeNull(); - expect( - screen - .getByRole("link", { - name: TUTORIAL_COPY.HOME_LABEL, - }) - .getAttribute("data-variant"), - ).toBe("outline"); - expect(screen.getByText(TUTORIAL_CONTROLS[0].detail)).not.toBeNull(); - expect(screen.getByText(TUTORIAL_CONTROLS[1].detail)).not.toBeNull(); - expect(screen.getByText(TUTORIAL_CONTROLS[2].detail)).not.toBeNull(); - expect(screen.getByText(TUTORIAL_CONTROLS[3].detail)).not.toBeNull(); - - const main = screen.getByRole("main"); - - expect(main.className).toContain("h-dvh"); - expect(main.className).toContain("overflow-y-auto"); - expect(main.className).toContain("overscroll-contain"); }); - it("keeps the dungeon entry CTA in the hero when authenticated", () => { + it("keeps the entry CTA disabled when unauthenticated", () => { mockUseAuthContext.mockReturnValue({ - authenticatedProfile: { - discriminator: "0420", - username: "rune-scribe", - }, - authStatus: AUTH_STATUS.AUTHENTICATED, - errorMessage: null, - handleUsernameFormSubmit: vi.fn(), - isAuthenticated: true, - isCheckingSession: false, - isUsernameModalOpen: false, - isUsernameSubmitting: false, - readyStatusLabel: "rune-scribe#0420", - suggestedUsername: "Rune_AshBearAAAA", + isAuthenticated: false, }); render(); const hero = screen - .getByRole("heading", { name: TUTORIAL_COPY.HEADING }) + .getByRole("heading", { name: "Play the system." }) .closest("header"); expect(hero).not.toBeNull(); - expect( - within(hero as HTMLElement).getByRole("link", { - name: TUTORIAL_COPY.CTA_LABEL, - }), - ).not.toBeNull(); - expect( - within(hero as HTMLElement) - .getByRole("link", { - name: TUTORIAL_COPY.CTA_LABEL, - }) - .getAttribute("data-variant"), - ).toBe("default"); - expect( - within(hero as HTMLElement) - .getByRole("link", { - name: TUTORIAL_COPY.HOME_LABEL, - }) - .getAttribute("data-variant"), - ).toBe("outline"); - expect(screen.getByText(TUTORIAL_COPY.SUBTITLE)).not.toBeNull(); + const entryButton = within(hero as HTMLElement).getByRole("button", { + name: "Enter Dungeon", + }); + + expect(entryButton).not.toBeNull(); + expect((entryButton as HTMLButtonElement).disabled).toBe(true); }); }); diff --git a/src/pages/tutorial/ui/TutorialPage.tsx b/src/pages/tutorial/ui/TutorialPage.tsx index cd1c6297..36b80f05 100644 --- a/src/pages/tutorial/ui/TutorialPage.tsx +++ b/src/pages/tutorial/ui/TutorialPage.tsx @@ -1,76 +1,224 @@ import { Link } from "@tanstack/react-router"; -import { BookOpenText, DoorOpen, House } from "lucide-react"; +import { DoorOpen, Gamepad2, Keyboard } from "lucide-react"; -import { AUTH_ROUTE_PATHS, useAuthContext } from "@/features/auth"; -import { Badge, Button, Card } from "@/shared/ui"; +import { useAuthContext } from "@/features/auth"; +import { + Badge, + Button, + Card, + CardContent, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/shared/ui"; +import { + MARKETING_NAVIGATION_ITEM_IDS, + MARKETING_ROUTES, + MarketingShell, +} from "@/widgets/marketing-shell"; -import { TUTORIAL_COPY } from "../config"; -import { TutorialControlsCard } from "./TutorialControlsCard"; -import { TutorialObjectivesCard } from "./TutorialObjectivesCard"; -import { TutorialTipsCard } from "./TutorialTipsCard"; +import { + TUTORIAL_CONCEPT_ROWS, + TUTORIAL_CONCEPTS_COPY, + TUTORIAL_CONTROLS_COPY, + TUTORIAL_COPY, + TUTORIAL_DESKTOP_CONTROLS, + TUTORIAL_FIRST_RUN_COPY, + TUTORIAL_FIRST_RUN_STEPS, + TUTORIAL_MOBILE_CONTROLS, + TUTORIAL_TAB_IDS, + TUTORIAL_TABS, +} from "../config"; export function TutorialPage() { const { isAuthenticated } = useAuthContext(); return ( -
-
- -
-
-
- - - {TUTORIAL_COPY.BADGE} - -
+
+
+

+ {TUTORIAL_COPY.HEADING} +

+

+ {TUTORIAL_COPY.SUBTITLE} +

-
-

- {TUTORIAL_COPY.HEADING} -

-

- {TUTORIAL_COPY.SUBTITLE} -

-
+ {isAuthenticated ? ( + + ) : ( + + )} +
+ + + + {TUTORIAL_TABS.map((tab) => ( + + {tab.label} + + ))} + + + +
+

+ {TUTORIAL_CONTROLS_COPY.SECTION_HEADING} +

-
- {isAuthenticated ? ( - - ) : ( - - )} - +
+ + +
+
+ +
+

+ {TUTORIAL_CONTROLS_COPY.DESKTOP_HEADING} +

+
+
    + {TUTORIAL_DESKTOP_CONTROLS.map((control) => ( +
  • +

    + {control.label} +

    + + {control.detail} + +
  • + ))} +
+
+
+ + + +
+
+ +
+

+ {TUTORIAL_CONTROLS_COPY.MOBILE_TABLET_HEADING} +

+
+
    + {TUTORIAL_MOBILE_CONTROLS.map((control) => ( +
  • +

    + {control.label} +

    +

    + {control.detail} +

    +
  • + ))} +
+
+
-
+ + + + +
+

+ {TUTORIAL_FIRST_RUN_COPY.SECTION_HEADING} +

+ +
    + {TUTORIAL_FIRST_RUN_STEPS.map((step, index) => ( +
  1. + + +
    +
    + {index + 1} +
    + {index < TUTORIAL_FIRST_RUN_STEPS.length - 1 ? ( +
    + ) : null} +
    +
    +

    + {step.label} +

    +

    + {step.detail} +

    +
    + + +
  2. + ))} +
+
+
+ + +
+

+ {TUTORIAL_CONCEPTS_COPY.SECTION_HEADING} +

-
- - - -
-
-
+
    + {TUTORIAL_CONCEPT_ROWS.map((concept) => ( +
  • + + +
    +

    + {concept.label} +

    + + {concept.monoLabel} + +
    +

    + {concept.detail} +

    +
    +
    +
  • + ))} +
+ + +
-
+ ); } diff --git a/src/pages/tutorial/ui/TutorialTipsCard.tsx b/src/pages/tutorial/ui/TutorialTipsCard.tsx deleted file mode 100644 index 66db0a28..00000000 --- a/src/pages/tutorial/ui/TutorialTipsCard.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Lightbulb } from "lucide-react"; - -import { TUTORIAL_COPY, TUTORIAL_TIPS } from "../config"; - -export function TutorialTipsCard() { - return ( -
-
-
- -
-
-

- {TUTORIAL_COPY.TIPS_HEADING} -

-

- {TUTORIAL_COPY.TIPS_DESCRIPTION} -

-
-
- -
    - {TUTORIAL_TIPS.map((tip) => ( -
  • -
  • - ))} -
-
- ); -} diff --git a/src/pages/tutorial/ui/index.ts b/src/pages/tutorial/ui/index.ts index 16ea6bcb..50a9dfd6 100644 --- a/src/pages/tutorial/ui/index.ts +++ b/src/pages/tutorial/ui/index.ts @@ -1,4 +1 @@ -export { TutorialControlsCard } from "./TutorialControlsCard"; -export { TutorialObjectivesCard } from "./TutorialObjectivesCard"; export { TutorialPage } from "./TutorialPage"; -export { TutorialTipsCard } from "./TutorialTipsCard"; diff --git a/src/widgets/marketing-shell/config/index.ts b/src/widgets/marketing-shell/config/index.ts new file mode 100644 index 00000000..c2485579 --- /dev/null +++ b/src/widgets/marketing-shell/config/index.ts @@ -0,0 +1,8 @@ +export { + MARKETING_FOOTER_LINKS, + MARKETING_NAVIGATION_ITEM_IDS, + MARKETING_NAVIGATION_ITEMS, + MARKETING_ROUTES, + MARKETING_SHELL_COPY, + type MarketingNavigationItemId, +} from "./marketingShellConfig"; diff --git a/src/widgets/marketing-shell/config/marketingShellConfig.ts b/src/widgets/marketing-shell/config/marketingShellConfig.ts new file mode 100644 index 00000000..b9c09cfa --- /dev/null +++ b/src/widgets/marketing-shell/config/marketingShellConfig.ts @@ -0,0 +1,59 @@ +export const MARKETING_NAVIGATION_ITEM_IDS = { + GUIDE: "guide", + CONCEPTS: "concepts", +} as const; + +export type MarketingNavigationItemId = + (typeof MARKETING_NAVIGATION_ITEM_IDS)[keyof typeof MARKETING_NAVIGATION_ITEM_IDS]; + +export const MARKETING_ROUTES = { + HOME: "/", + GAME: "/game", + GUIDE: "/tutorial", + CONCEPTS: "/concepts", + GITHUB_REPOSITORY: "https://github.com/Teeldinho/runestone", +} as const; + +export const MARKETING_SHELL_COPY = { + BRAND_NAME: "RUNESTONE", + BRAND_RUNE_SEGMENT: "RUNE", + BRAND_STONE_SEGMENT: "STONE", + COMPACT_BRAND_RUNE_SEGMENT: "R", + COMPACT_BRAND_STONE_SEGMENT: "S", + ENTER_DUNGEON_LABEL: "Enter Dungeon", + DRAWER_TITLE: "Runestone", + DRAWER_DESCRIPTION: "Navigate the playable architecture guide.", + FOOTER_COPYRIGHT: "© 2026 Runestone", + FOOTER_TAGLINE: "Playable architecture", +} as const; + +export const MARKETING_NAVIGATION_ITEMS = [ + { + id: MARKETING_NAVIGATION_ITEM_IDS.GUIDE, + label: "Guide", + to: MARKETING_ROUTES.GUIDE, + }, + { + id: MARKETING_NAVIGATION_ITEM_IDS.CONCEPTS, + label: "Concepts", + to: MARKETING_ROUTES.CONCEPTS, + }, +] as const; + +export const MARKETING_FOOTER_LINKS = [ + { + label: "Guide", + to: MARKETING_ROUTES.GUIDE, + type: "internal", + }, + { + label: "Concepts", + to: MARKETING_ROUTES.CONCEPTS, + type: "internal", + }, + { + label: "GitHub", + to: MARKETING_ROUTES.GITHUB_REPOSITORY, + type: "external", + }, +] as const; diff --git a/src/widgets/marketing-shell/index.ts b/src/widgets/marketing-shell/index.ts new file mode 100644 index 00000000..614d3ee3 --- /dev/null +++ b/src/widgets/marketing-shell/index.ts @@ -0,0 +1,6 @@ +export { + MARKETING_NAVIGATION_ITEM_IDS, + MARKETING_ROUTES, + type MarketingNavigationItemId, +} from "./config"; +export { MarketingShell, RunestoneLogo } from "./ui"; diff --git a/src/widgets/marketing-shell/lib/createMarketingNavigationViewModel.test.ts b/src/widgets/marketing-shell/lib/createMarketingNavigationViewModel.test.ts new file mode 100644 index 00000000..95668e3f --- /dev/null +++ b/src/widgets/marketing-shell/lib/createMarketingNavigationViewModel.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { + MARKETING_FOOTER_LINKS, + MARKETING_NAVIGATION_ITEM_IDS, + MARKETING_NAVIGATION_ITEMS, +} from "../config"; +import { createMarketingNavigationViewModel } from "./createMarketingNavigationViewModel"; + +describe("createMarketingNavigationViewModel", () => { + it("marks the active navigation item", () => { + const viewModel = createMarketingNavigationViewModel({ + activeNavigationItemId: MARKETING_NAVIGATION_ITEM_IDS.CONCEPTS, + footerLinks: MARKETING_FOOTER_LINKS, + navigationItems: MARKETING_NAVIGATION_ITEMS, + }); + + expect( + viewModel.navigationItems.find( + (item) => item.id === MARKETING_NAVIGATION_ITEM_IDS.CONCEPTS, + )?.isActive, + ).toBe(true); + expect( + viewModel.navigationItems.find( + (item) => item.id === MARKETING_NAVIGATION_ITEM_IDS.GUIDE, + )?.isActive, + ).toBe(false); + }); + + it("leaves all navigation items inactive when active item is null", () => { + const viewModel = createMarketingNavigationViewModel({ + activeNavigationItemId: null, + footerLinks: MARKETING_FOOTER_LINKS, + navigationItems: MARKETING_NAVIGATION_ITEMS, + }); + + expect(viewModel.navigationItems.every((item) => !item.isActive)).toBe( + true, + ); + }); +}); diff --git a/src/widgets/marketing-shell/lib/createMarketingNavigationViewModel.ts b/src/widgets/marketing-shell/lib/createMarketingNavigationViewModel.ts new file mode 100644 index 00000000..fe594c26 --- /dev/null +++ b/src/widgets/marketing-shell/lib/createMarketingNavigationViewModel.ts @@ -0,0 +1,47 @@ +import type { + MARKETING_FOOTER_LINKS, + MARKETING_NAVIGATION_ITEMS, + MarketingNavigationItemId, +} from "../config"; + +type MarketingNavigationItem = (typeof MARKETING_NAVIGATION_ITEMS)[number]; + +type MarketingFooterLink = (typeof MARKETING_FOOTER_LINKS)[number]; + +type CreateMarketingNavigationViewModelInput = { + activeNavigationItemId: MarketingNavigationItemId | null; + footerLinks: readonly MarketingFooterLink[]; + navigationItems: readonly MarketingNavigationItem[]; +}; + +type MarketingNavigationLinkViewModel = MarketingNavigationItem & { + isActive: boolean; +}; + +type MarketingFooterLinkViewModel = MarketingFooterLink; + +type MarketingNavigationViewModel = { + footerLinks: readonly MarketingFooterLinkViewModel[]; + navigationItems: readonly MarketingNavigationLinkViewModel[]; +}; + +export const createMarketingNavigationViewModel = ({ + activeNavigationItemId, + footerLinks, + navigationItems, +}: CreateMarketingNavigationViewModelInput): MarketingNavigationViewModel => { + return { + footerLinks, + navigationItems: navigationItems.map((item) => ({ + ...item, + isActive: item.id === activeNavigationItemId, + })), + }; +}; + +export type { + CreateMarketingNavigationViewModelInput, + MarketingFooterLinkViewModel, + MarketingNavigationLinkViewModel, + MarketingNavigationViewModel, +}; diff --git a/src/widgets/marketing-shell/lib/index.ts b/src/widgets/marketing-shell/lib/index.ts new file mode 100644 index 00000000..f9cb151e --- /dev/null +++ b/src/widgets/marketing-shell/lib/index.ts @@ -0,0 +1,7 @@ +export { + type CreateMarketingNavigationViewModelInput, + createMarketingNavigationViewModel, + type MarketingFooterLinkViewModel, + type MarketingNavigationLinkViewModel, + type MarketingNavigationViewModel, +} from "./createMarketingNavigationViewModel"; diff --git a/src/widgets/marketing-shell/model/index.ts b/src/widgets/marketing-shell/model/index.ts new file mode 100644 index 00000000..dee1dfa5 --- /dev/null +++ b/src/widgets/marketing-shell/model/index.ts @@ -0,0 +1 @@ +export { useMarketingNavigation } from "./useMarketingNavigation"; diff --git a/src/widgets/marketing-shell/model/useMarketingNavigation.ts b/src/widgets/marketing-shell/model/useMarketingNavigation.ts new file mode 100644 index 00000000..95af245d --- /dev/null +++ b/src/widgets/marketing-shell/model/useMarketingNavigation.ts @@ -0,0 +1,16 @@ +import { + MARKETING_FOOTER_LINKS, + MARKETING_NAVIGATION_ITEMS, + type MarketingNavigationItemId, +} from "../config"; +import { createMarketingNavigationViewModel } from "../lib"; + +export const useMarketingNavigation = ( + activeNavigationItemId: MarketingNavigationItemId | null, +) => { + return createMarketingNavigationViewModel({ + activeNavigationItemId, + footerLinks: MARKETING_FOOTER_LINKS, + navigationItems: MARKETING_NAVIGATION_ITEMS, + }); +}; diff --git a/src/widgets/marketing-shell/ui/MarketingFooter.tsx b/src/widgets/marketing-shell/ui/MarketingFooter.tsx new file mode 100644 index 00000000..17699426 --- /dev/null +++ b/src/widgets/marketing-shell/ui/MarketingFooter.tsx @@ -0,0 +1,46 @@ +import { Link } from "@tanstack/react-router"; + +import { MARKETING_SHELL_COPY } from "../config"; +import type { MarketingNavigationViewModel } from "../lib"; + +type MarketingFooterProps = { + viewModel: MarketingNavigationViewModel; +}; + +export function MarketingFooter({ viewModel }: MarketingFooterProps) { + return ( +
+
+

{MARKETING_SHELL_COPY.FOOTER_COPYRIGHT}

+ + + +

+ {MARKETING_SHELL_COPY.FOOTER_TAGLINE} +

+
+
+ ); +} diff --git a/src/widgets/marketing-shell/ui/MarketingHeader.tsx b/src/widgets/marketing-shell/ui/MarketingHeader.tsx new file mode 100644 index 00000000..f870a4aa --- /dev/null +++ b/src/widgets/marketing-shell/ui/MarketingHeader.tsx @@ -0,0 +1,94 @@ +import { Link } from "@tanstack/react-router"; +import { DoorOpen } from "lucide-react"; + +import { cn } from "@/shared/lib"; +import { Button } from "@/shared/ui"; + +import { MARKETING_ROUTES, MARKETING_SHELL_COPY } from "../config"; +import type { MarketingNavigationViewModel } from "../lib"; +import { MarketingNavigationDrawer } from "./MarketingNavigationDrawer"; +import { RunestoneLogo } from "./RunestoneLogo"; + +type MarketingHeaderProps = { + isAuthenticated: boolean; + viewModel: MarketingNavigationViewModel; +}; + +export function MarketingHeader({ + isAuthenticated, + viewModel, +}: MarketingHeaderProps) { + return ( +
+
+
+ +
+
+ +
+ + + +
+ {isAuthenticated ? ( + + ) : ( + + )} + + {isAuthenticated ? ( + + ) : ( + + )} + + +
+
+
+ ); +} diff --git a/src/widgets/marketing-shell/ui/MarketingNavigationDrawer.tsx b/src/widgets/marketing-shell/ui/MarketingNavigationDrawer.tsx new file mode 100644 index 00000000..9b8552b4 --- /dev/null +++ b/src/widgets/marketing-shell/ui/MarketingNavigationDrawer.tsx @@ -0,0 +1,102 @@ +import { Link } from "@tanstack/react-router"; +import { DoorOpen, Github, Menu } from "lucide-react"; + +import { cn } from "@/shared/lib"; +import { + Button, + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, + Separator, +} from "@/shared/ui"; + +import { MARKETING_ROUTES, MARKETING_SHELL_COPY } from "../config"; +import type { MarketingNavigationViewModel } from "../lib"; + +type MarketingNavigationDrawerProps = { + isAuthenticated: boolean; + viewModel: MarketingNavigationViewModel; +}; + +export function MarketingNavigationDrawer({ + isAuthenticated, + viewModel, +}: MarketingNavigationDrawerProps) { + return ( + + + + + + + {MARKETING_SHELL_COPY.DRAWER_TITLE} + + {MARKETING_SHELL_COPY.DRAWER_DESCRIPTION} + + + + + + + + + {isAuthenticated ? ( + + + + ) : ( + + )} + + + + ); +} diff --git a/src/widgets/marketing-shell/ui/MarketingShell.tsx b/src/widgets/marketing-shell/ui/MarketingShell.tsx new file mode 100644 index 00000000..30254127 --- /dev/null +++ b/src/widgets/marketing-shell/ui/MarketingShell.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from "react"; + +import type { MarketingNavigationItemId } from "../config"; +import { useMarketingNavigation } from "../model"; +import { MarketingFooter } from "./MarketingFooter"; +import { MarketingHeader } from "./MarketingHeader"; + +type MarketingShellProps = { + activeNavigationItemId: MarketingNavigationItemId | null; + children: ReactNode; + isAuthenticated: boolean; +}; + +export function MarketingShell({ + activeNavigationItemId, + children, + isAuthenticated, +}: MarketingShellProps) { + const viewModel = useMarketingNavigation(activeNavigationItemId); + + return ( +
+