diff --git a/README.md b/README.md index 847754c3..d8d39173 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ Once a commit is triggered, the Lefthook orchestrator executes four mandatory ch * **Biome Static Analysis**: Audits and formats code in milliseconds to enforce strict formatting guidelines and prevent styling anti-patterns. * **Steiger FSD Architectural Bounds**: Validates Feature-Sliced Design layers (`app -> pages -> widgets -> features -> entities -> shared`), blocking illegal circular dependencies and ensuring clean vertical slice separation. * **Segment Purity Validator**: Enforces a strict separation of concerns, ensuring static data remain isolated in `config/` folders while functional utilities are placed exclusively in `lib/` modules. -* **Vitest Coverage Suite**: Automatically runs our 900+ unit and integration tests on every integration attempt to prevent structural regressions. +* **Vitest Coverage Suite**: Automatically runs our 1,000+ unit and integration tests on every integration attempt to prevent structural regressions. ### Spec-Driven Development (SDD) Before any subsystem is implemented, a comprehensive technical blueprint is created. This specification details exact scope boundaries, architectural constraints, edge cases, and expected acceptance metrics, guiding implementing agents and maintaining code integrity. diff --git a/public/marketing/game-running-bg.png b/public/marketing/game-running-bg.png new file mode 100644 index 00000000..9cb8d141 Binary files /dev/null and b/public/marketing/game-running-bg.png differ diff --git a/public/marketing/hex-grid.svg b/public/marketing/hex-grid.svg new file mode 100644 index 00000000..82ec049b --- /dev/null +++ b/public/marketing/hex-grid.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + diff --git a/src/app/routes/_marketing.concepts.tsx b/src/app/routes/_marketing.concepts.tsx new file mode 100644 index 00000000..a461d3de --- /dev/null +++ b/src/app/routes/_marketing.concepts.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ConceptsPage } from "@/pages/concepts"; + +export const Route = createFileRoute("/_marketing/concepts")({ + component: ConceptsPage, +}); diff --git a/src/app/routes/index.tsx b/src/app/routes/_marketing.index.tsx similarity index 69% rename from src/app/routes/index.tsx rename to src/app/routes/_marketing.index.tsx index 6776d58b..aceec7c4 100644 --- a/src/app/routes/index.tsx +++ b/src/app/routes/_marketing.index.tsx @@ -2,6 +2,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { HomePage } from "@/pages/home"; -export const Route = createFileRoute("/")({ +export const Route = createFileRoute("/_marketing/")({ component: HomePage, }); diff --git a/src/app/routes/_marketing.tsx b/src/app/routes/_marketing.tsx new file mode 100644 index 00000000..9882258d --- /dev/null +++ b/src/app/routes/_marketing.tsx @@ -0,0 +1,25 @@ +import { createFileRoute, Outlet } from "@tanstack/react-router"; + +import { UsernameModal } from "@/features/auth"; +import { + MarketingPageFrame, + MarketingShell, + useMarketingLayoutRoute, +} from "@/widgets/marketing-shell"; + +export const Route = createFileRoute("/_marketing")({ + component: MarketingLayoutRoute, +}); + +function MarketingLayoutRoute() { + const { shellProps, usernameModalProps } = useMarketingLayoutRoute(); + + return ( + + + + + + + ); +} diff --git a/src/app/routes/tutorial.tsx b/src/app/routes/_marketing.tutorial.tsx similarity index 68% rename from src/app/routes/tutorial.tsx rename to src/app/routes/_marketing.tutorial.tsx index 38fc0257..6cdcdd9f 100644 --- a/src/app/routes/tutorial.tsx +++ b/src/app/routes/_marketing.tutorial.tsx @@ -2,6 +2,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { TutorialPage } from "@/pages/tutorial"; -export const Route = createFileRoute("/tutorial")({ +export const Route = createFileRoute("/_marketing/tutorial")({ component: TutorialPage, }); diff --git a/src/app/styles/globals.css b/src/app/styles/globals.css index 619831ef..e408777e 100644 --- a/src/app/styles/globals.css +++ b/src/app/styles/globals.css @@ -112,8 +112,12 @@ html, body { - height: 100%; - overflow: hidden; + min-height: 100%; + overflow-x: hidden; +} + +html { + scrollbar-gutter: stable; } body { diff --git a/src/features/auth/config/authConfig.ts b/src/features/auth/config/authConfig.ts index c3b11073..0ea5affb 100644 --- a/src/features/auth/config/authConfig.ts +++ b/src/features/auth/config/authConfig.ts @@ -29,6 +29,7 @@ export const AUTH_COPY = { MODAL_TITLE: "Enter the dungeon", MODAL_DESCRIPTION: "Use the suggested name or edit it before saving your progress and leaderboard entry.", + MODAL_KEEP_READING_LABEL: "Keep reading", USERNAME_LABEL: "Name", USERNAME_PLACEHOLDER: "Your name", USERNAME_SUBMIT_LABEL: "Enter the dungeon", diff --git a/src/features/auth/config/authEvents.ts b/src/features/auth/config/authEvents.ts index 0c1dedd0..4571f0cd 100644 --- a/src/features/auth/config/authEvents.ts +++ b/src/features/auth/config/authEvents.ts @@ -5,6 +5,8 @@ export const AUTH_EVENTS = { USERNAME_SUBMIT_REQUESTED: "USERNAME_SUBMIT_REQUESTED", USERNAME_SUBMIT_SUCCEEDED: "USERNAME_SUBMIT_SUCCEEDED", USERNAME_SUBMIT_FAILED: "USERNAME_SUBMIT_FAILED", + USERNAME_ENTRY_REQUESTED: "USERNAME_ENTRY_REQUESTED", + USERNAME_ENTRY_DEFERRED: "USERNAME_ENTRY_DEFERRED", SIGN_OUT_REQUESTED: "SIGN_OUT_REQUESTED", } as const; diff --git a/src/features/auth/config/authMachineConfig.ts b/src/features/auth/config/authMachineConfig.ts index c5ee784b..c19d1f82 100644 --- a/src/features/auth/config/authMachineConfig.ts +++ b/src/features/auth/config/authMachineConfig.ts @@ -3,6 +3,8 @@ export const AUTH_INITIAL_CONTEXT = { profile: null, pendingUsername: null, errorMessage: null, + isUsernameEntryRequested: false, + isUsernameEntryDeferred: false, }; export const AUTH_CONTEXT_KEYS = { @@ -10,4 +12,6 @@ export const AUTH_CONTEXT_KEYS = { PROFILE: "profile", PENDING_USERNAME: "pendingUsername", ERROR_MESSAGE: "errorMessage", + IS_USERNAME_ENTRY_REQUESTED: "isUsernameEntryRequested", + IS_USERNAME_ENTRY_DEFERRED: "isUsernameEntryDeferred", } as const; diff --git a/src/features/auth/lib/createAuthContextValue.test.ts b/src/features/auth/lib/createAuthContextValue.test.ts index 614a5789..2e0e9b84 100644 --- a/src/features/auth/lib/createAuthContextValue.test.ts +++ b/src/features/auth/lib/createAuthContextValue.test.ts @@ -30,6 +30,8 @@ const createAuthSnapshot = ({ context: { profile, errorMessage, + isUsernameEntryRequested: false, + isUsernameEntryDeferred: false, }, matches: (status: string) => matchedStates.includes(status), }); @@ -38,6 +40,8 @@ describe("createAuthContextValue", () => { it("builds authenticated context state with ready label", () => { const handleUsernameFormSubmit = vi.fn(); const handleSessionBootstrapRetry = vi.fn(); + const handleUsernameEntryRequest = vi.fn(); + const handleUsernameEntryDismiss = vi.fn(); const snapshot = createAuthSnapshot({ value: AUTH_STATUS.AUTHENTICATED, profile: TEST_USER_PROFILE, @@ -50,6 +54,8 @@ describe("createAuthContextValue", () => { suggestedUsername: "Rune_AshBearAAAA", handleUsernameFormSubmit, handleSessionBootstrapRetry, + handleUsernameEntryRequest, + handleUsernameEntryDismiss, }); expect(authContextValue.authStatus).toBe(AUTH_STATUS.AUTHENTICATED); @@ -66,6 +72,12 @@ describe("createAuthContextValue", () => { expect(authContextValue.handleSessionBootstrapRetry).toBe( handleSessionBootstrapRetry, ); + expect(authContextValue.handleUsernameEntryRequest).toBe( + handleUsernameEntryRequest, + ); + expect(authContextValue.handleUsernameEntryDismiss).toBe( + handleUsernameEntryDismiss, + ); }); it("opens username modal for requires-username and submitting states", () => { @@ -79,6 +91,8 @@ describe("createAuthContextValue", () => { suggestedUsername: "Rune_AshBearAAAA", handleUsernameFormSubmit: vi.fn(), handleSessionBootstrapRetry: vi.fn(), + handleUsernameEntryRequest: vi.fn(), + handleUsernameEntryDismiss: vi.fn(), }); expect(requiresUsernameContext.isUsernameModalOpen).toBe(true); @@ -95,9 +109,65 @@ describe("createAuthContextValue", () => { suggestedUsername: "Rune_AshBearAAAA", handleUsernameFormSubmit: vi.fn(), handleSessionBootstrapRetry: vi.fn(), + handleUsernameEntryRequest: vi.fn(), + handleUsernameEntryDismiss: vi.fn(), }); expect(submittingContext.isUsernameSubmitting).toBe(true); expect(submittingContext.isUsernameModalOpen).toBe(true); }); + + it("opens username modal when entry is requested during session checking", () => { + const authContextValue = createAuthContextValue({ + snapshot: { + ...createAuthSnapshot({ + value: AUTH_STATUS.CHECKING_SESSION, + profile: null, + errorMessage: null, + matchedStates: [AUTH_STATUS.CHECKING_SESSION], + }), + context: { + profile: null, + errorMessage: null, + isUsernameEntryRequested: true, + isUsernameEntryDeferred: false, + }, + }, + suggestedUsername: "Rune_AshBearAAAA", + handleUsernameFormSubmit: vi.fn(), + handleSessionBootstrapRetry: vi.fn(), + handleUsernameEntryRequest: vi.fn(), + handleUsernameEntryDismiss: vi.fn(), + }); + + expect(authContextValue.isCheckingSession).toBe(true); + expect(authContextValue.isUsernameModalOpen).toBe(true); + }); + + it("keeps the username modal closed when entry is deferred", () => { + const authContextValue = createAuthContextValue({ + snapshot: { + ...createAuthSnapshot({ + value: AUTH_STATUS.REQUIRES_USERNAME, + profile: null, + errorMessage: null, + matchedStates: [AUTH_STATUS.REQUIRES_USERNAME], + }), + context: { + profile: null, + errorMessage: null, + isUsernameEntryRequested: false, + isUsernameEntryDeferred: true, + }, + }, + suggestedUsername: "Rune_AshBearAAAA", + handleUsernameFormSubmit: vi.fn(), + handleSessionBootstrapRetry: vi.fn(), + handleUsernameEntryRequest: vi.fn(), + handleUsernameEntryDismiss: vi.fn(), + }); + + expect(authContextValue.isUsernameModalOpen).toBe(false); + expect(authContextValue.isAuthenticated).toBe(false); + }); }); diff --git a/src/features/auth/lib/createAuthContextValue.ts b/src/features/auth/lib/createAuthContextValue.ts index 5bbb3772..81485f3f 100644 --- a/src/features/auth/lib/createAuthContextValue.ts +++ b/src/features/auth/lib/createAuthContextValue.ts @@ -11,6 +11,8 @@ type AuthSnapshotLike = { context: { profile: UserProfile | null; errorMessage: string | null; + isUsernameEntryRequested: boolean; + isUsernameEntryDeferred: boolean; }; matches: (status: AuthStatusValue) => boolean; }; @@ -20,6 +22,8 @@ type CreateAuthContextValueInput = { suggestedUsername: string; handleSessionBootstrapRetry: AuthContextValue["handleSessionBootstrapRetry"]; handleUsernameFormSubmit: AuthContextValue["handleUsernameFormSubmit"]; + handleUsernameEntryRequest: AuthContextValue["handleUsernameEntryRequest"]; + handleUsernameEntryDismiss: AuthContextValue["handleUsernameEntryDismiss"]; }; const createAuthContextValue = ({ @@ -27,6 +31,8 @@ const createAuthContextValue = ({ suggestedUsername, handleSessionBootstrapRetry, handleUsernameFormSubmit, + handleUsernameEntryRequest, + handleUsernameEntryDismiss, }: CreateAuthContextValueInput): AuthContextValue => { const isCheckingSession = snapshot.matches(AUTH_STATUS.CHECKING_SESSION); const isAuthenticated = snapshot.matches(AUTH_STATUS.AUTHENTICATED); @@ -41,7 +47,12 @@ const createAuthContextValue = ({ isCheckingSession, isAuthenticated, isUsernameModalOpen: - snapshot.matches(AUTH_STATUS.REQUIRES_USERNAME) || isUsernameSubmitting, + (snapshot.matches(AUTH_STATUS.CHECKING_SESSION) && + snapshot.context.isUsernameEntryRequested && + !snapshot.context.isUsernameEntryDeferred) || + (snapshot.matches(AUTH_STATUS.REQUIRES_USERNAME) && + !snapshot.context.isUsernameEntryDeferred) || + isUsernameSubmitting, isUsernameSubmitting, readyStatusLabel: snapshot.context.profile ? formatUserDisplayTag( @@ -50,6 +61,8 @@ const createAuthContextValue = ({ ) : null, suggestedUsername, + handleUsernameEntryRequest, + handleUsernameEntryDismiss, handleSessionBootstrapRetry, handleUsernameFormSubmit, }; diff --git a/src/features/auth/model/authMachine.test.ts b/src/features/auth/model/authMachine.test.ts index 01754849..7e883e49 100644 --- a/src/features/auth/model/authMachine.test.ts +++ b/src/features/auth/model/authMachine.test.ts @@ -132,4 +132,48 @@ describe("authMachine", () => { "unable to create user", ); }); + + it("defers and reopens the username entry without authenticating", () => { + const actor = createActor(authMachine).start(); + + actor.send({ + type: AUTH_EVENTS.SESSION_BOOTSTRAPPED, + uuid: "session-uuid", + profile: null, + }); + actor.send({ + type: AUTH_EVENTS.USERNAME_ENTRY_DEFERRED, + }); + + expect(actor.getSnapshot().value).toBe(AUTH_STATUS.REQUIRES_USERNAME); + expect(actor.getSnapshot().context.profile).toBeNull(); + expect(actor.getSnapshot().context.isUsernameEntryDeferred).toBe(true); + + actor.send({ + type: AUTH_EVENTS.USERNAME_ENTRY_REQUESTED, + }); + + expect(actor.getSnapshot().value).toBe(AUTH_STATUS.REQUIRES_USERNAME); + expect(actor.getSnapshot().context.isUsernameEntryDeferred).toBe(false); + }); + + it("can request username entry while session bootstrap is still checking", () => { + const actor = createActor(authMachine).start(); + + actor.send({ + type: AUTH_EVENTS.USERNAME_ENTRY_REQUESTED, + }); + + expect(actor.getSnapshot().value).toBe(AUTH_STATUS.CHECKING_SESSION); + expect(actor.getSnapshot().context.isUsernameEntryRequested).toBe(true); + expect(actor.getSnapshot().context.isUsernameEntryDeferred).toBe(false); + + actor.send({ + type: AUTH_EVENTS.USERNAME_ENTRY_DEFERRED, + }); + + expect(actor.getSnapshot().value).toBe(AUTH_STATUS.CHECKING_SESSION); + expect(actor.getSnapshot().context.isUsernameEntryRequested).toBe(false); + expect(actor.getSnapshot().context.isUsernameEntryDeferred).toBe(true); + }); }); diff --git a/src/features/auth/model/authMachine.ts b/src/features/auth/model/authMachine.ts index 79a7e04c..720ddb4d 100644 --- a/src/features/auth/model/authMachine.ts +++ b/src/features/auth/model/authMachine.ts @@ -22,6 +22,18 @@ export const authMachine = setup({ initial: AUTH_STATUS.CHECKING_SESSION, context: AUTH_INITIAL_CONTEXT as AuthMachineContext, on: { + [AUTH_EVENTS.USERNAME_ENTRY_REQUESTED]: { + actions: assign(() => ({ + [AUTH_CONTEXT_KEYS.IS_USERNAME_ENTRY_REQUESTED]: true, + [AUTH_CONTEXT_KEYS.IS_USERNAME_ENTRY_DEFERRED]: false, + })), + }, + [AUTH_EVENTS.USERNAME_ENTRY_DEFERRED]: { + actions: assign(() => ({ + [AUTH_CONTEXT_KEYS.IS_USERNAME_ENTRY_REQUESTED]: false, + [AUTH_CONTEXT_KEYS.IS_USERNAME_ENTRY_DEFERRED]: true, + })), + }, [AUTH_EVENTS.SESSION_BOOTSTRAPPED]: [ { target: `.${AUTH_STATUS.AUTHENTICATED}`, @@ -31,6 +43,8 @@ export const authMachine = setup({ [AUTH_CONTEXT_KEYS.PROFILE]: event.profile, [AUTH_CONTEXT_KEYS.PENDING_USERNAME]: null, [AUTH_CONTEXT_KEYS.ERROR_MESSAGE]: null, + [AUTH_CONTEXT_KEYS.IS_USERNAME_ENTRY_REQUESTED]: false, + [AUTH_CONTEXT_KEYS.IS_USERNAME_ENTRY_DEFERRED]: false, })), }, { @@ -40,6 +54,8 @@ export const authMachine = setup({ [AUTH_CONTEXT_KEYS.PROFILE]: null, [AUTH_CONTEXT_KEYS.PENDING_USERNAME]: null, [AUTH_CONTEXT_KEYS.ERROR_MESSAGE]: null, + [AUTH_CONTEXT_KEYS.IS_USERNAME_ENTRY_REQUESTED]: false, + [AUTH_CONTEXT_KEYS.IS_USERNAME_ENTRY_DEFERRED]: false, })), }, ], @@ -50,6 +66,7 @@ export const authMachine = setup({ [AUTH_CONTEXT_KEYS.PROFILE]: null, [AUTH_CONTEXT_KEYS.PENDING_USERNAME]: null, [AUTH_CONTEXT_KEYS.ERROR_MESSAGE]: event.errorMessage, + [AUTH_CONTEXT_KEYS.IS_USERNAME_ENTRY_REQUESTED]: false, })), }, [AUTH_EVENTS.SESSION_BOOTSTRAP_RETRY_REQUESTED]: { @@ -59,6 +76,7 @@ export const authMachine = setup({ [AUTH_CONTEXT_KEYS.PROFILE]: null, [AUTH_CONTEXT_KEYS.PENDING_USERNAME]: null, [AUTH_CONTEXT_KEYS.ERROR_MESSAGE]: null, + [AUTH_CONTEXT_KEYS.IS_USERNAME_ENTRY_REQUESTED]: false, })), }, }, @@ -72,6 +90,8 @@ export const authMachine = setup({ actions: assign(({ event }) => ({ [AUTH_CONTEXT_KEYS.PENDING_USERNAME]: event.username, [AUTH_CONTEXT_KEYS.ERROR_MESSAGE]: null, + [AUTH_CONTEXT_KEYS.IS_USERNAME_ENTRY_REQUESTED]: false, + [AUTH_CONTEXT_KEYS.IS_USERNAME_ENTRY_DEFERRED]: false, })), }, }, @@ -84,6 +104,8 @@ export const authMachine = setup({ [AUTH_CONTEXT_KEYS.PROFILE]: event.profile, [AUTH_CONTEXT_KEYS.PENDING_USERNAME]: null, [AUTH_CONTEXT_KEYS.ERROR_MESSAGE]: null, + [AUTH_CONTEXT_KEYS.IS_USERNAME_ENTRY_REQUESTED]: false, + [AUTH_CONTEXT_KEYS.IS_USERNAME_ENTRY_DEFERRED]: false, })), }, [AUTH_EVENTS.USERNAME_SUBMIT_FAILED]: { @@ -91,6 +113,8 @@ export const authMachine = setup({ actions: assign(({ event }) => ({ [AUTH_CONTEXT_KEYS.PENDING_USERNAME]: null, [AUTH_CONTEXT_KEYS.ERROR_MESSAGE]: event.errorMessage, + [AUTH_CONTEXT_KEYS.IS_USERNAME_ENTRY_REQUESTED]: false, + [AUTH_CONTEXT_KEYS.IS_USERNAME_ENTRY_DEFERRED]: false, })), }, }, @@ -103,6 +127,7 @@ export const authMachine = setup({ [AUTH_CONTEXT_KEYS.PROFILE]: null, [AUTH_CONTEXT_KEYS.PENDING_USERNAME]: null, [AUTH_CONTEXT_KEYS.ERROR_MESSAGE]: null, + [AUTH_CONTEXT_KEYS.IS_USERNAME_ENTRY_REQUESTED]: false, })), }, }, diff --git a/src/features/auth/model/types.ts b/src/features/auth/model/types.ts index 0ecdfa82..aa4c6ffc 100644 --- a/src/features/auth/model/types.ts +++ b/src/features/auth/model/types.ts @@ -9,6 +9,8 @@ export type AuthMachineContext = { profile: UserProfile | null; pendingUsername: string | null; errorMessage: string | null; + isUsernameEntryRequested: boolean; + isUsernameEntryDeferred: boolean; }; export type AuthMachineEvent = @@ -37,6 +39,12 @@ export type AuthMachineEvent = type: (typeof AUTH_EVENTS)["USERNAME_SUBMIT_FAILED"]; errorMessage: string; } + | { + type: (typeof AUTH_EVENTS)["USERNAME_ENTRY_REQUESTED"]; + } + | { + type: (typeof AUTH_EVENTS)["USERNAME_ENTRY_DEFERRED"]; + } | { type: (typeof AUTH_EVENTS)["SIGN_OUT_REQUESTED"]; }; @@ -55,6 +63,8 @@ export type AuthContextValue = { isUsernameSubmitting: boolean; readyStatusLabel: string | null; suggestedUsername: string; + handleUsernameEntryRequest: () => void; + handleUsernameEntryDismiss: () => void; handleSessionBootstrapRetry: () => void; handleUsernameFormSubmit: (input: UsernameFormInput) => Promise; }; diff --git a/src/features/auth/model/useAuth.test.tsx b/src/features/auth/model/useAuth.test.tsx index 8db07f40..e77444a5 100644 --- a/src/features/auth/model/useAuth.test.tsx +++ b/src/features/auth/model/useAuth.test.tsx @@ -3,7 +3,7 @@ import { renderHook } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; -import { AUTH_STATUS } from "../config"; +import { AUTH_EVENTS, AUTH_STATUS } from "../config"; const mockUseMachine = vi.hoisted(() => vi.fn()); const mockUseAuthSessionBootstrap = vi.hoisted(() => vi.fn()); @@ -49,6 +49,8 @@ describe("useAuth", () => { context: { profile: null, errorMessage: null, + isUsernameEntryRequested: false, + isUsernameEntryDeferred: false, }, matches: (status: string) => status === AUTH_STATUS.REQUIRES_USERNAME, }; @@ -87,6 +89,8 @@ describe("useAuth", () => { suggestedUsername: "Rune_AshBearAAAA", handleSessionBootstrapRetry, handleUsernameFormSubmit, + handleUsernameEntryRequest: expect.any(Function), + handleUsernameEntryDismiss: expect.any(Function), }), ); @@ -94,4 +98,52 @@ describe("useAuth", () => { expect(mockCreateSuggestedUsername).toHaveBeenCalledTimes(1); }); + + it("exposes handlers for reopening and deferring username entry", () => { + const sendAuthEvent = vi.fn(); + const snapshot = { + value: AUTH_STATUS.REQUIRES_USERNAME, + context: { + profile: null, + errorMessage: null, + isUsernameEntryRequested: false, + isUsernameEntryDeferred: true, + }, + matches: (status: string) => status === AUTH_STATUS.REQUIRES_USERNAME, + }; + + mockUseMachine.mockReturnValue([snapshot, sendAuthEvent]); + mockUseAuthSessionBootstrap.mockReturnValue({ + handleSessionBootstrapRetry: vi.fn(), + sessionUuid: "session-uuid", + }); + mockUseAuthUsernameSubmission.mockReturnValue({ + handleUsernameFormSubmit: vi.fn(), + }); + mockCreateSuggestedUsername.mockReturnValue("Rune_AshBearAAAA"); + mockCreateAuthContextValue.mockImplementation((input) => ({ + ...input, + authStatus: AUTH_STATUS.REQUIRES_USERNAME, + authenticatedProfile: null, + errorMessage: null, + isCheckingSession: false, + isAuthenticated: false, + isUsernameModalOpen: false, + isUsernameSubmitting: false, + readyStatusLabel: null, + suggestedUsername: input.suggestedUsername, + })); + + const { result } = renderHook(() => useAuth()); + + result.current.handleUsernameEntryRequest(); + result.current.handleUsernameEntryDismiss(); + + expect(sendAuthEvent).toHaveBeenCalledWith({ + type: AUTH_EVENTS.USERNAME_ENTRY_REQUESTED, + }); + expect(sendAuthEvent).toHaveBeenCalledWith({ + type: AUTH_EVENTS.USERNAME_ENTRY_DEFERRED, + }); + }); }); diff --git a/src/features/auth/model/useAuth.tsx b/src/features/auth/model/useAuth.tsx index 2d23d045..4cffb2c6 100644 --- a/src/features/auth/model/useAuth.tsx +++ b/src/features/auth/model/useAuth.tsx @@ -1,6 +1,7 @@ import { useMachine } from "@xstate/react"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; +import { AUTH_EVENTS } from "../config"; import { createAuthContextValue, createSuggestedUsername } from "../lib"; import { authMachine } from "./authMachine"; @@ -18,6 +19,16 @@ export const useAuth = (): AuthContextValue => { sendAuthEvent, sessionUuid, }); + const handleUsernameEntryRequest = useCallback(() => { + sendAuthEvent({ + type: AUTH_EVENTS.USERNAME_ENTRY_REQUESTED, + }); + }, [sendAuthEvent]); + const handleUsernameEntryDismiss = useCallback(() => { + sendAuthEvent({ + type: AUTH_EVENTS.USERNAME_ENTRY_DEFERRED, + }); + }, [sendAuthEvent]); return useMemo( () => @@ -26,12 +37,16 @@ export const useAuth = (): AuthContextValue => { suggestedUsername, handleSessionBootstrapRetry, handleUsernameFormSubmit, + handleUsernameEntryRequest, + handleUsernameEntryDismiss, }), [ snapshot, suggestedUsername, handleSessionBootstrapRetry, handleUsernameFormSubmit, + handleUsernameEntryRequest, + handleUsernameEntryDismiss, ], ); }; diff --git a/src/features/auth/ui/UsernameModal.test.tsx b/src/features/auth/ui/UsernameModal.test.tsx new file mode 100644 index 00000000..974eca4c --- /dev/null +++ b/src/features/auth/ui/UsernameModal.test.tsx @@ -0,0 +1,42 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { AUTH_COPY } from "../config"; + +import { UsernameModal } from "./UsernameModal"; + +afterEach(cleanup); + +describe("UsernameModal", () => { + it("offers dungeon entry and a keep-reading action", () => { + const handleSubmit = vi.fn(); + const handleKeepReading = vi.fn(); + + render( + , + ); + + expect( + screen.getByRole("button", { + name: AUTH_COPY.USERNAME_SUBMIT_LABEL, + }), + ).not.toBeNull(); + + fireEvent.click( + screen.getByRole("button", { + name: AUTH_COPY.MODAL_KEEP_READING_LABEL, + }), + ); + + expect(handleKeepReading).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/features/auth/ui/UsernameModal.tsx b/src/features/auth/ui/UsernameModal.tsx index dc6e4f6e..2e443135 100644 --- a/src/features/auth/ui/UsernameModal.tsx +++ b/src/features/auth/ui/UsernameModal.tsx @@ -1,7 +1,10 @@ import { + Button, Dialog, + DialogClose, DialogContent, DialogDescription, + DialogFooter, DialogHeader, DialogTitle, } from "@/shared/ui"; @@ -15,6 +18,7 @@ type UsernameModalProps = { isOpen: boolean; isSubmitting: boolean; suggestedUsername: string; + onKeepReading: () => void; onSubmit: (input: UsernameFormInput) => Promise; }; @@ -23,6 +27,7 @@ export function UsernameModal({ isOpen, isSubmitting, suggestedUsername, + onKeepReading, onSubmit, }: UsernameModalProps) { return ( @@ -43,6 +48,20 @@ export function UsernameModal({ suggestedUsername={suggestedUsername} onSubmit={onSubmit} /> + + + + + + ); diff --git a/src/pages/concepts/config/conceptsCopy.ts b/src/pages/concepts/config/conceptsCopy.ts new file mode 100644 index 00000000..e6cce7c2 --- /dev/null +++ b/src/pages/concepts/config/conceptsCopy.ts @@ -0,0 +1,102 @@ +import { CONCEPTS_SECTION_ICON_KEYS } from "./conceptsIconConfig"; + +export const CONCEPTS_COPY = { + CTA_LABEL: "Enter Dungeon", + HEADING: "System concepts.", + MAPPING_HEADING: "Concept map", + SECONDARY_LINK_LABEL: "Read the Guide", + HERO_SUBTITLE: + "Runestone maps statechart ideas to dungeon objects so software behavior can be explored spatially.", + CTA_HEADING: "Ready to inspect the dungeon?", + CTA_SUBTITLE: "Use the guide as a map, then inspect the concepts in motion.", +} as const; + +export const CONCEPTS_SECTION_IDS = { + ACTOR: "actor-independent-loop", + CONTEXT: "context-inventory-hp-current-room", + EVENT: "event-input-prompt", + GUARD: "guard-locked-door", + STATE: "state-room", + TRANSITION: "transition-corridor", +} as const; + +export const CONCEPTS_MAPPING_TONES = { + ACTIVE: "active", + AVAILABLE: "available", + SEALED: "sealed", +} as const; + +export type ConceptsMappingTone = + (typeof CONCEPTS_MAPPING_TONES)[keyof typeof CONCEPTS_MAPPING_TONES]; + +export const CONCEPTS_MAPPING_TONE_CLASS_NAMES = { + [CONCEPTS_MAPPING_TONES.ACTIVE]: + "border-dungeon-gold/30 bg-dungeon-gold/10 text-dungeon-gold", + [CONCEPTS_MAPPING_TONES.AVAILABLE]: + "border-dungeon-gold/30 bg-dungeon-gold/10 text-dungeon-gold", + [CONCEPTS_MAPPING_TONES.SEALED]: + "border-dungeon-rune-sealed/50 bg-dungeon-rune-sealed/10 text-dungeon-rune-sealed", +} as const; + +export const CONCEPTS_TITLE_TONE_CLASS_NAMES = { + [CONCEPTS_MAPPING_TONES.ACTIVE]: "text-panel-title", + [CONCEPTS_MAPPING_TONES.AVAILABLE]: "text-panel-title", + [CONCEPTS_MAPPING_TONES.SEALED]: "text-dungeon-rune-sealed", +} as const; + +export const CONCEPTS_SECTIONS = [ + { + detail: + "A state marks the current room or mode, holding the system in one place until conditions change.", + iconKey: CONCEPTS_SECTION_ICON_KEYS.STATE, + id: CONCEPTS_SECTION_IDS.STATE, + source: "State", + target: "Room", + tone: CONCEPTS_MAPPING_TONES.ACTIVE, + }, + { + detail: + "A transition carries the run from one room to the next when an event resolves successfully.", + iconKey: CONCEPTS_SECTION_ICON_KEYS.TRANSITION, + id: CONCEPTS_SECTION_IDS.TRANSITION, + source: "Transition", + target: "Corridor", + tone: CONCEPTS_MAPPING_TONES.AVAILABLE, + }, + { + detail: + "Inputs and prompts trigger evaluation, which can open movement paths or advance other systems.", + iconKey: CONCEPTS_SECTION_ICON_KEYS.EVENT, + id: CONCEPTS_SECTION_IDS.EVENT, + source: "Event", + target: "Input or prompt", + tone: CONCEPTS_MAPPING_TONES.AVAILABLE, + }, + { + detail: + "A guard blocks traversal until the required state, key, or condition is satisfied.", + iconKey: CONCEPTS_SECTION_ICON_KEYS.GUARD, + id: CONCEPTS_SECTION_IDS.GUARD, + source: "Guard", + target: "Locked door", + tone: CONCEPTS_MAPPING_TONES.SEALED, + }, + { + detail: + "Context keeps the values a run depends on, such as inventory, HP, and current room state.", + iconKey: CONCEPTS_SECTION_ICON_KEYS.CONTEXT, + id: CONCEPTS_SECTION_IDS.CONTEXT, + source: "Context", + target: "Inventory, HP, current room", + tone: CONCEPTS_MAPPING_TONES.AVAILABLE, + }, + { + detail: + "Actors run in isolated loops so camera, player, or audio behavior can respond independently.", + iconKey: CONCEPTS_SECTION_ICON_KEYS.ACTOR, + id: CONCEPTS_SECTION_IDS.ACTOR, + source: "Actor", + target: "Independent loop", + tone: CONCEPTS_MAPPING_TONES.ACTIVE, + }, +] as const; diff --git a/src/pages/concepts/config/conceptsIconConfig.ts b/src/pages/concepts/config/conceptsIconConfig.ts new file mode 100644 index 00000000..e51e6460 --- /dev/null +++ b/src/pages/concepts/config/conceptsIconConfig.ts @@ -0,0 +1,11 @@ +export const CONCEPTS_SECTION_ICON_KEYS = { + ACTOR: "actor", + CONTEXT: "context", + EVENT: "event", + GUARD: "guard", + STATE: "state", + TRANSITION: "transition", +} as const; + +export type ConceptsSectionIconKey = + (typeof CONCEPTS_SECTION_ICON_KEYS)[keyof typeof CONCEPTS_SECTION_ICON_KEYS]; diff --git a/src/pages/concepts/config/index.ts b/src/pages/concepts/config/index.ts new file mode 100644 index 00000000..7e87e752 --- /dev/null +++ b/src/pages/concepts/config/index.ts @@ -0,0 +1,13 @@ +export { + CONCEPTS_COPY, + CONCEPTS_MAPPING_TONE_CLASS_NAMES, + CONCEPTS_MAPPING_TONES, + CONCEPTS_SECTION_IDS, + CONCEPTS_SECTIONS, + CONCEPTS_TITLE_TONE_CLASS_NAMES, + type ConceptsMappingTone, +} from "./conceptsCopy"; +export { + CONCEPTS_SECTION_ICON_KEYS, + type ConceptsSectionIconKey, +} from "./conceptsIconConfig"; 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/lib/createConceptsPageViewModel.test.ts b/src/pages/concepts/lib/createConceptsPageViewModel.test.ts new file mode 100644 index 00000000..c8d655fe --- /dev/null +++ b/src/pages/concepts/lib/createConceptsPageViewModel.test.ts @@ -0,0 +1,46 @@ +import { + ArrowRightLeft, + DoorOpen, + Lock, + MousePointerClick, + Package, + Workflow, +} from "lucide-react"; +import { describe, expect, it, vi } from "vitest"; + +import { CONCEPTS_SECTION_IDS } from "../config"; + +import { createConceptsPageViewModel } from "./createConceptsPageViewModel"; + +describe("createConceptsPageViewModel", () => { + it("maps auth state into CTA props and section view models", () => { + const handleUsernameEntryRequest = vi.fn(); + + const viewModel = createConceptsPageViewModel({ + handleUsernameEntryRequest, + isAuthenticated: true, + }); + + expect(viewModel.ctaProps.isAuthenticated).toBe(true); + expect(viewModel.ctaProps.onEntryRequest).toBe(handleUsernameEntryRequest); + expect(viewModel.mappingSectionProps.sections[0]?.id).toBe( + CONCEPTS_SECTION_IDS.STATE, + ); + expect(viewModel.mappingSectionProps.sections[0]?.icon).toBe(DoorOpen); + expect(viewModel.mappingSectionProps.sections[1]?.icon).toBe( + ArrowRightLeft, + ); + expect(viewModel.mappingSectionProps.sections[2]?.icon).toBe( + MousePointerClick, + ); + expect(viewModel.mappingSectionProps.sections[3]?.icon).toBe(Lock); + expect(viewModel.mappingSectionProps.sections[4]?.icon).toBe(Package); + expect(viewModel.mappingSectionProps.sections[5]?.icon).toBe(Workflow); + expect(viewModel.mappingSectionProps.sections[3]?.titleClassName).toBe( + "text-dungeon-rune-sealed", + ); + expect(viewModel.mappingSectionProps.sections[3]?.id).toBe( + CONCEPTS_SECTION_IDS.GUARD, + ); + }); +}); diff --git a/src/pages/concepts/lib/createConceptsPageViewModel.ts b/src/pages/concepts/lib/createConceptsPageViewModel.ts new file mode 100644 index 00000000..ff9d9418 --- /dev/null +++ b/src/pages/concepts/lib/createConceptsPageViewModel.ts @@ -0,0 +1,65 @@ +import type { AuthContextValue } from "@/features/auth"; + +import { + CONCEPTS_COPY, + CONCEPTS_MAPPING_TONE_CLASS_NAMES, + CONCEPTS_MAPPING_TONES, + CONCEPTS_SECTIONS, + CONCEPTS_TITLE_TONE_CLASS_NAMES, +} from "../config"; + +import { resolveConceptsIcon } from "./resolveConceptsIcon"; + +type CreateConceptsPageViewModelInput = Pick< + AuthContextValue, + "handleUsernameEntryRequest" | "isAuthenticated" +>; + +export type ConceptsMappingSectionViewModel = { + badge: string; + detail: string; + icon: ReturnType; + iconClassName: string; + id: string; + isSealed: boolean; + title: string; + titleClassName: string; +}; + +type ConceptsPageViewModel = { + ctaProps: { + isAuthenticated: boolean; + onEntryRequest: () => void; + }; + mappingSectionProps: { + heading: string; + sections: readonly ConceptsMappingSectionViewModel[]; + }; +}; + +export const createConceptsPageViewModel = ({ + handleUsernameEntryRequest, + isAuthenticated, +}: CreateConceptsPageViewModelInput): ConceptsPageViewModel => { + return { + ctaProps: { + isAuthenticated, + onEntryRequest: handleUsernameEntryRequest, + }, + mappingSectionProps: { + heading: CONCEPTS_COPY.MAPPING_HEADING, + sections: CONCEPTS_SECTIONS.map((section) => ({ + badge: section.source, + detail: section.detail, + icon: resolveConceptsIcon(section.iconKey), + iconClassName: CONCEPTS_MAPPING_TONE_CLASS_NAMES[section.tone], + id: section.id, + isSealed: section.tone === CONCEPTS_MAPPING_TONES.SEALED, + title: section.target, + titleClassName: CONCEPTS_TITLE_TONE_CLASS_NAMES[section.tone], + })), + }, + }; +}; + +export type { ConceptsPageViewModel, CreateConceptsPageViewModelInput }; diff --git a/src/pages/concepts/lib/index.ts b/src/pages/concepts/lib/index.ts new file mode 100644 index 00000000..90d5c8e2 --- /dev/null +++ b/src/pages/concepts/lib/index.ts @@ -0,0 +1,7 @@ +export { + type ConceptsMappingSectionViewModel, + type ConceptsPageViewModel, + type CreateConceptsPageViewModelInput, + createConceptsPageViewModel, +} from "./createConceptsPageViewModel"; +export { resolveConceptsIcon } from "./resolveConceptsIcon"; diff --git a/src/pages/concepts/lib/resolveConceptsIcon.test.ts b/src/pages/concepts/lib/resolveConceptsIcon.test.ts new file mode 100644 index 00000000..ea218bf9 --- /dev/null +++ b/src/pages/concepts/lib/resolveConceptsIcon.test.ts @@ -0,0 +1,33 @@ +import { + ArrowRightLeft, + DoorOpen, + Lock, + MousePointerClick, + Package, + Workflow, +} from "lucide-react"; +import { describe, expect, it } from "vitest"; + +import { CONCEPTS_SECTION_ICON_KEYS } from "../config"; +import { resolveConceptsIcon } from "./resolveConceptsIcon"; + +describe("resolveConceptsIcon", () => { + it("resolves configured concepts icons", () => { + expect(resolveConceptsIcon(CONCEPTS_SECTION_ICON_KEYS.STATE)).toBe( + DoorOpen, + ); + expect(resolveConceptsIcon(CONCEPTS_SECTION_ICON_KEYS.TRANSITION)).toBe( + ArrowRightLeft, + ); + expect(resolveConceptsIcon(CONCEPTS_SECTION_ICON_KEYS.EVENT)).toBe( + MousePointerClick, + ); + expect(resolveConceptsIcon(CONCEPTS_SECTION_ICON_KEYS.GUARD)).toBe(Lock); + expect(resolveConceptsIcon(CONCEPTS_SECTION_ICON_KEYS.CONTEXT)).toBe( + Package, + ); + expect(resolveConceptsIcon(CONCEPTS_SECTION_ICON_KEYS.ACTOR)).toBe( + Workflow, + ); + }); +}); diff --git a/src/pages/concepts/lib/resolveConceptsIcon.ts b/src/pages/concepts/lib/resolveConceptsIcon.ts new file mode 100644 index 00000000..610028f2 --- /dev/null +++ b/src/pages/concepts/lib/resolveConceptsIcon.ts @@ -0,0 +1,33 @@ +import { + ArrowRightLeft, + DoorOpen, + Lock, + type LucideIcon, + MousePointerClick, + Package, + Workflow, +} from "lucide-react"; + +import { + CONCEPTS_SECTION_ICON_KEYS, + type ConceptsSectionIconKey, +} from "../config"; + +export const resolveConceptsIcon = ( + iconKey: ConceptsSectionIconKey, +): LucideIcon => { + switch (iconKey) { + case CONCEPTS_SECTION_ICON_KEYS.ACTOR: + return Workflow; + case CONCEPTS_SECTION_ICON_KEYS.CONTEXT: + return Package; + case CONCEPTS_SECTION_ICON_KEYS.EVENT: + return MousePointerClick; + case CONCEPTS_SECTION_ICON_KEYS.GUARD: + return Lock; + case CONCEPTS_SECTION_ICON_KEYS.STATE: + return DoorOpen; + case CONCEPTS_SECTION_ICON_KEYS.TRANSITION: + return ArrowRightLeft; + } +}; diff --git a/src/pages/concepts/model/index.ts b/src/pages/concepts/model/index.ts new file mode 100644 index 00000000..7764516e --- /dev/null +++ b/src/pages/concepts/model/index.ts @@ -0,0 +1 @@ +export { useConceptsPage } from "./useConceptsPage"; diff --git a/src/pages/concepts/model/useConceptsPage.ts b/src/pages/concepts/model/useConceptsPage.ts new file mode 100644 index 00000000..758c9fe2 --- /dev/null +++ b/src/pages/concepts/model/useConceptsPage.ts @@ -0,0 +1,12 @@ +import { useAuthContext } from "@/features/auth"; + +import { createConceptsPageViewModel } from "../lib"; + +export const useConceptsPage = () => { + const { handleUsernameEntryRequest, isAuthenticated } = useAuthContext(); + + return createConceptsPageViewModel({ + handleUsernameEntryRequest, + isAuthenticated, + }); +}; diff --git a/src/pages/concepts/ui/ConceptsCtaSection.tsx b/src/pages/concepts/ui/ConceptsCtaSection.tsx new file mode 100644 index 00000000..8bb91c55 --- /dev/null +++ b/src/pages/concepts/ui/ConceptsCtaSection.tsx @@ -0,0 +1,63 @@ +import { Link } from "@tanstack/react-router"; +import { DoorOpen } from "lucide-react"; + +import { Button } from "@/shared/ui"; +import { MARKETING_ROUTES } from "@/widgets/marketing-shell"; + +import { CONCEPTS_COPY } from "../config"; + +type ConceptsCtaSectionProps = { + isAuthenticated: boolean; + onEntryRequest: () => void; +}; + +export function ConceptsCtaSection({ + isAuthenticated, + onEntryRequest, +}: ConceptsCtaSectionProps) { + return ( +
+
+
+

+ {CONCEPTS_COPY.CTA_HEADING} +

+

+ {CONCEPTS_COPY.CTA_SUBTITLE} +

+
+ +
+ + {isAuthenticated ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/src/pages/concepts/ui/ConceptsHero.tsx b/src/pages/concepts/ui/ConceptsHero.tsx new file mode 100644 index 00000000..b402c325 --- /dev/null +++ b/src/pages/concepts/ui/ConceptsHero.tsx @@ -0,0 +1,14 @@ +import { CONCEPTS_COPY } from "../config"; + +export function ConceptsHero() { + return ( +
+

+ {CONCEPTS_COPY.HEADING} +

+

+ {CONCEPTS_COPY.HERO_SUBTITLE} +

+
+ ); +} diff --git a/src/pages/concepts/ui/ConceptsMappingSection.tsx b/src/pages/concepts/ui/ConceptsMappingSection.tsx new file mode 100644 index 00000000..b8e2124f --- /dev/null +++ b/src/pages/concepts/ui/ConceptsMappingSection.tsx @@ -0,0 +1,76 @@ +import { cn } from "@/shared/lib"; +import { Badge } from "@/shared/ui"; + +import type { ConceptsMappingSectionViewModel } from "../lib"; + +type ConceptsMappingSectionProps = { + heading: string; + sections: readonly ConceptsMappingSectionViewModel[]; +}; + +export function ConceptsMappingSection({ + heading, + sections, +}: ConceptsMappingSectionProps) { + return ( +
+
+ {sections.map((section) => { + const SectionIcon = section.icon; + + return ( +
+
+
+ + {section.badge} + + +
+
+
+ +
+

+ {section.title} +

+ +

+ {section.detail} +

+
+
+
+
+ ); + })} +
+ +

+ {heading} +

+
+ ); +} diff --git a/src/pages/concepts/ui/ConceptsPage.test.tsx b/src/pages/concepts/ui/ConceptsPage.test.tsx new file mode 100644 index 00000000..e842db18 --- /dev/null +++ b/src/pages/concepts/ui/ConceptsPage.test.tsx @@ -0,0 +1,70 @@ +// @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({ + handleUsernameEntryRequest: vi.fn(), + isAuthenticated: true, + }); +}); + +describe("ConceptsPage", () => { + it("renders the standalone concepts page without tabs", () => { + render(); + + expect(screen.getByText("System concepts.")).not.toBeNull(); + expect(screen.getByText("Room")).not.toBeNull(); + expect(screen.getByText("Corridor")).not.toBeNull(); + expect(screen.getByText("Input or prompt")).not.toBeNull(); + expect(screen.getByText("Locked door")).not.toBeNull(); + expect(screen.getByText("Inventory, HP, current room")).not.toBeNull(); + expect(screen.getByText("Independent loop")).not.toBeNull(); + expect(screen.getByText("Ready to inspect the dungeon?")).not.toBeNull(); + expect( + screen.getByText( + "Use the guide as a map, then inspect the concepts in motion.", + ), + ).not.toBeNull(); + expect( + screen.getByText( + "Runestone maps statechart ideas to dungeon objects so software behavior can be explored spatially.", + ), + ).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 guide link from the concepts CTA", () => { + render(); + + expect(screen.getByRole("link", { name: "Read the Guide" })).not.toBeNull(); + }); +}); diff --git a/src/pages/concepts/ui/ConceptsPage.tsx b/src/pages/concepts/ui/ConceptsPage.tsx new file mode 100644 index 00000000..613cd9c9 --- /dev/null +++ b/src/pages/concepts/ui/ConceptsPage.tsx @@ -0,0 +1,18 @@ +import { useConceptsPage } from "../model"; +import { ConceptsCtaSection } from "./ConceptsCtaSection"; +import { ConceptsHero } from "./ConceptsHero"; +import { ConceptsMappingSection } from "./ConceptsMappingSection"; + +export function ConceptsPage() { + const { ctaProps, mappingSectionProps } = useConceptsPage(); + + return ( + <> + + + + + + + ); +} 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/game/config/gamePageControls.ts b/src/pages/game/config/gamePageControls.ts index b020bb0f..0171eb39 100644 --- a/src/pages/game/config/gamePageControls.ts +++ b/src/pages/game/config/gamePageControls.ts @@ -6,6 +6,9 @@ export const GAME_PAGE_CONTROLS = { UNMUTE_ARIA_LABEL: "Unmute audio", UNMUTE_TOOLTIP_LABEL: "Unmute audio", }, + NAVIGATION: { + HOME_ARIA_LABEL: "Return to landing page", + }, LEADERBOARD: { BUTTON_LABEL: "Rankings", ARIA_LABEL: "Open Leaderboard", diff --git a/src/pages/game/ui/GamePage.test.tsx b/src/pages/game/ui/GamePage.test.tsx index da2f6cf1..4d6b0ed4 100644 --- a/src/pages/game/ui/GamePage.test.tsx +++ b/src/pages/game/ui/GamePage.test.tsx @@ -330,6 +330,21 @@ vi.mock("@/pages/game/model", () => { }; }); +vi.mock("@tanstack/react-router", () => ({ + Link: ({ + children, + to, + ...props + }: { + children: React.ReactNode; + to: string; + }) => ( + + {children} + + ), +})); + vi.mock("@/features/settings", () => ({ useSettingsForm: vi.fn(() => ({ postprocessingEnabled: true, @@ -448,6 +463,11 @@ describe("GamePage", () => { GAME_PAGE_CONTROLS.LEADERBOARD.ARIA_LABEL, GAME_PAGE_CONTROLS.SETTINGS.ARIA_LABEL, ]); + expect( + within(screen.getByRole("banner")).getByRole("link", { + name: "RUNESTONE", + }), + ).not.toBeNull(); expect( screen.getByRole("button", { @@ -481,6 +501,11 @@ describe("GamePage", () => { ); expect(screen.getByTestId("touch-joystick-widget")).not.toBeNull(); + expect( + screen.getByRole("link", { + name: GAME_PAGE_CONTROLS.NAVIGATION.HOME_ARIA_LABEL, + }), + ).not.toBeNull(); expect( screen.getByRole("button", { name: "Enter Library", diff --git a/src/pages/game/ui/GamePageDesktopHeader.tsx b/src/pages/game/ui/GamePageDesktopHeader.tsx index 0d1674bd..11876e22 100644 --- a/src/pages/game/ui/GamePageDesktopHeader.tsx +++ b/src/pages/game/ui/GamePageDesktopHeader.tsx @@ -1,9 +1,11 @@ +import { Link } from "@tanstack/react-router"; import { Trophy, Volume2, VolumeX } from "lucide-react"; import { GAME_PAGE_CONTROLS } from "@/pages/game/config"; import { useGamePageDesktopHeaderModel } from "@/pages/game/model"; import { Button, Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui"; import { LeaderboardSheet } from "@/widgets/leaderboard-panel"; +import { MARKETING_ROUTES } from "@/widgets/marketing-shell"; import { GamePageDesktopSettingsAction } from "./GamePageDesktopSettingsAction"; @@ -14,9 +16,12 @@ export function GamePageDesktopHeader() { return (
- + RUNESTONE - + · Floor I
diff --git a/src/pages/game/ui/GamePageMobileTopBar.tsx b/src/pages/game/ui/GamePageMobileTopBar.tsx index 1770d251..770d25ea 100644 --- a/src/pages/game/ui/GamePageMobileTopBar.tsx +++ b/src/pages/game/ui/GamePageMobileTopBar.tsx @@ -1,8 +1,11 @@ -import { RotateCcw } from "lucide-react"; +import { Link } from "@tanstack/react-router"; +import { Home, RotateCcw } from "lucide-react"; +import { GAME_PAGE_CONTROLS } 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"; export function GamePageMobileTopBar() { const viewModel = useGamePageMobileTopBarModel(); @@ -10,6 +13,18 @@ export function GamePageMobileTopBar() { return (
+ + Camera diff --git a/src/pages/home/config/homeCopy.ts b/src/pages/home/config/homeCopy.ts index 675b8436..6b4da5a0 100644 --- a/src/pages/home/config/homeCopy.ts +++ b/src/pages/home/config/homeCopy.ts @@ -1,31 +1,21 @@ 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 Map", + MANIFEST_PATH_SUBTITLE: + "Watch the current run resolve into rooms, corridors, guards, and context.", + 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_FEATURES = [ - { - detail: "Rooms and doors react as you move forward.", - title: "The dungeon shifts as you progress.", - }, - { - detail: - "Switch between third-person, top-down, first-person, and free-orbital views.", - title: "Pick the view that feels easiest to play.", - }, - { - detail: - "The guide covers movement, interaction, attack, and camera controls.", - title: "Learn the basics before you descend.", - }, -] as const; - export const HOME_STATUS_COPY = { CHECKING_SESSION: { badge: "Getting ready", diff --git a/src/pages/home/config/homeManifestConfig.ts b/src/pages/home/config/homeManifestConfig.ts new file mode 100644 index 00000000..9b0f3276 --- /dev/null +++ b/src/pages/home/config/homeManifestConfig.ts @@ -0,0 +1,49 @@ +export const HOME_MANIFEST_TONES = { + ACTIVE: "active", + AVAILABLE: "available", + SEALED: "sealed", +} as const; + +export type HomeManifestTone = + (typeof HOME_MANIFEST_TONES)[keyof typeof HOME_MANIFEST_TONES]; + +export const HOME_MANIFEST_TONE_CLASS_NAMES = { + [HOME_MANIFEST_TONES.ACTIVE]: "text-dungeon-gold", + [HOME_MANIFEST_TONES.AVAILABLE]: "text-dungeon-gold", + [HOME_MANIFEST_TONES.SEALED]: "text-dungeon-rune-sealed", +} as const; + +export const HOME_MANIFEST_FOCUS_ITEM = { + badge: "Guard Condition", + description: + "Evaluates context before allowing passage. If the key is missing, the path stays blocked.", + title: "Locked Door", + tone: HOME_MANIFEST_TONES.SEALED, +} as const; + +export const HOME_MANIFEST_NODES = [ + { + description: "Initialize run", + id: "entrance", + title: "Entrance", + tone: HOME_MANIFEST_TONES.ACTIVE, + }, + { + description: "Transition", + id: "corridor", + title: "Corridor", + tone: HOME_MANIFEST_TONES.AVAILABLE, + }, + { + description: "Guard condition", + id: "locked-door", + title: "Locked Door", + tone: HOME_MANIFEST_TONES.SEALED, + }, + { + description: "Use context", + id: "inventory", + title: "Inventory", + tone: HOME_MANIFEST_TONES.AVAILABLE, + }, +] as const; diff --git a/src/pages/home/config/homeRuntimeConfig.ts b/src/pages/home/config/homeRuntimeConfig.ts new file mode 100644 index 00000000..744d02ac --- /dev/null +++ b/src/pages/home/config/homeRuntimeConfig.ts @@ -0,0 +1,17 @@ +export const HOME_RUNTIME_PANELS = [ + { + description: "The playable dungeon frame anchors the learning experience.", + id: "viewport", + title: "Viewport", + }, + { + description: "The active room maps to the current state.", + id: "statechart", + title: "Statechart", + }, + { + description: "Context explains why paths open or remain locked.", + id: "inspector", + title: "Inspector", + }, +] as const; diff --git a/src/pages/home/config/homeTeachingConfig.ts b/src/pages/home/config/homeTeachingConfig.ts new file mode 100644 index 00000000..94fb3638 --- /dev/null +++ b/src/pages/home/config/homeTeachingConfig.ts @@ -0,0 +1,64 @@ +import { HOME_TEACHING_ICON_KEYS } from "./homeTeachingIconConfig"; + +export const HOME_TEACHING_TONES = { + ACCENT: "accent", + PRIMARY: "primary", + SEALED: "sealed", +} as const; + +export type HomeTeachingTone = + (typeof HOME_TEACHING_TONES)[keyof typeof HOME_TEACHING_TONES]; + +export const HOME_TEACHING_TONE_CLASS_NAMES = { + [HOME_TEACHING_TONES.ACCENT]: "text-dungeon-gold", + [HOME_TEACHING_TONES.PRIMARY]: "text-dungeon-gold", + [HOME_TEACHING_TONES.SEALED]: "text-dungeon-rune-sealed", +} as const; + +export const HOME_TEACHING_FEATURES = [ + { + description: + "Each state becomes a distinct room with one clear point of occupancy.", + iconKey: HOME_TEACHING_ICON_KEYS.DOOR_OPEN, + id: "states-become-rooms", + isFeatured: true, + title: "States become rooms", + tone: HOME_TEACHING_TONES.PRIMARY, + }, + { + description: + "Events are actions that open the next corridor when they resolve.", + iconKey: HOME_TEACHING_ICON_KEYS.ZAP, + id: "events-move-system", + isFeatured: false, + title: "Events move the system", + tone: HOME_TEACHING_TONES.ACCENT, + }, + { + description: + "Guards stop traversal until the required state or inventory is present.", + iconKey: HOME_TEACHING_ICON_KEYS.LOCK, + id: "guards-control-progression", + isFeatured: false, + title: "Guards control progression", + tone: HOME_TEACHING_TONES.SEALED, + }, + { + description: + "Actors run their own loops and stay isolated from the main machine.", + iconKey: HOME_TEACHING_ICON_KEYS.WORKFLOW, + id: "actors-stay-isolated", + isFeatured: false, + title: "Actors stay isolated", + tone: HOME_TEACHING_TONES.PRIMARY, + }, + { + description: + "Inventory, HP, and current room data shape which paths remain valid.", + iconKey: HOME_TEACHING_ICON_KEYS.PACKAGE, + id: "context-changes-paths", + isFeatured: false, + title: "Context changes paths", + tone: HOME_TEACHING_TONES.PRIMARY, + }, +] as const; diff --git a/src/pages/home/config/homeTeachingIconConfig.ts b/src/pages/home/config/homeTeachingIconConfig.ts new file mode 100644 index 00000000..4aa4301b --- /dev/null +++ b/src/pages/home/config/homeTeachingIconConfig.ts @@ -0,0 +1,10 @@ +export const HOME_TEACHING_ICON_KEYS = { + DOOR_OPEN: "door-open", + LOCK: "lock", + PACKAGE: "package", + WORKFLOW: "workflow", + ZAP: "zap", +} as const; + +export type HomeTeachingIconKey = + (typeof HOME_TEACHING_ICON_KEYS)[keyof typeof HOME_TEACHING_ICON_KEYS]; diff --git a/src/pages/home/config/homeTranslationConfig.ts b/src/pages/home/config/homeTranslationConfig.ts new file mode 100644 index 00000000..e709dd28 --- /dev/null +++ b/src/pages/home/config/homeTranslationConfig.ts @@ -0,0 +1,44 @@ +export const HOME_TRANSLATION_TONES = { + ACCENT: "accent", + PRIMARY: "primary", + SEALED: "sealed", +} as const; + +export type HomeTranslationTone = + (typeof HOME_TRANSLATION_TONES)[keyof typeof HOME_TRANSLATION_TONES]; + +export const HOME_TRANSLATION_TONE_CLASS_NAMES = { + [HOME_TRANSLATION_TONES.ACCENT]: "text-dungeon-gold", + [HOME_TRANSLATION_TONES.PRIMARY]: "text-panel-title", + [HOME_TRANSLATION_TONES.SEALED]: "text-dungeon-rune-sealed", +} as const; + +export const HOME_TRANSLATION_ARROW_CLASS_NAME = "text-dungeon-gold" as const; + +export const HOME_TRANSLATION_RAIL = [ + { + label: "State", + target: "Room", + tone: HOME_TRANSLATION_TONES.PRIMARY, + }, + { + label: "Transition", + target: "Corridor", + tone: HOME_TRANSLATION_TONES.PRIMARY, + }, + { + label: "Guard", + target: "Locked Door", + tone: HOME_TRANSLATION_TONES.ACCENT, + }, + { + label: "Context", + target: "Inventory", + tone: HOME_TRANSLATION_TONES.PRIMARY, + }, + { + label: "Actor", + target: "Loop", + tone: HOME_TRANSLATION_TONES.PRIMARY, + }, +] as const; diff --git a/src/pages/home/config/index.ts b/src/pages/home/config/index.ts index c7a4e12c..41a9eea3 100644 --- a/src/pages/home/config/index.ts +++ b/src/pages/home/config/index.ts @@ -1 +1,29 @@ -export { HOME_COPY, HOME_FEATURES, HOME_STATUS_COPY } from "./homeCopy"; +export { + HOME_COPY, + HOME_STATUS_COPY, +} from "./homeCopy"; +export { + HOME_MANIFEST_FOCUS_ITEM, + HOME_MANIFEST_NODES, + HOME_MANIFEST_TONE_CLASS_NAMES, + HOME_MANIFEST_TONES, + type HomeManifestTone, +} from "./homeManifestConfig"; +export { HOME_RUNTIME_PANELS } from "./homeRuntimeConfig"; +export { + HOME_TEACHING_FEATURES, + HOME_TEACHING_TONE_CLASS_NAMES, + HOME_TEACHING_TONES, + type HomeTeachingTone, +} from "./homeTeachingConfig"; +export { + HOME_TEACHING_ICON_KEYS, + type HomeTeachingIconKey, +} from "./homeTeachingIconConfig"; +export { + HOME_TRANSLATION_ARROW_CLASS_NAME, + HOME_TRANSLATION_RAIL, + HOME_TRANSLATION_TONE_CLASS_NAMES, + HOME_TRANSLATION_TONES, + type HomeTranslationTone, +} from "./homeTranslationConfig"; diff --git a/src/pages/home/lib/createHomePageViewModel.test.ts b/src/pages/home/lib/createHomePageViewModel.test.ts new file mode 100644 index 00000000..f42ed3dc --- /dev/null +++ b/src/pages/home/lib/createHomePageViewModel.test.ts @@ -0,0 +1,36 @@ +import { DoorOpen } from "lucide-react"; +import { describe, expect, it, vi } from "vitest"; + +import { AUTH_STATUS } from "@/features/auth"; + +import { createHomePageViewModel } from "./createHomePageViewModel"; + +describe("createHomePageViewModel", () => { + it("maps auth and marketing data into page props", () => { + const handleSessionBootstrapRetry = vi.fn(); + const handleUsernameEntryRequest = vi.fn(); + + const viewModel = createHomePageViewModel({ + authStatus: AUTH_STATUS.AUTHENTICATED, + errorMessage: null, + handleSessionBootstrapRetry, + handleUsernameEntryRequest, + isAuthenticated: true, + readyStatusLabel: "Rune_AshBearAAAA", + }); + + expect(viewModel.heroProps.authStatus).toBe(AUTH_STATUS.AUTHENTICATED); + expect(viewModel.heroProps.errorMessage).toBeNull(); + expect(viewModel.heroProps.isAuthenticated).toBe(true); + expect(viewModel.heroProps.onEntryRequest).toBe(handleUsernameEntryRequest); + expect(viewModel.heroProps.onRetry).toBe(handleSessionBootstrapRetry); + expect(viewModel.heroProps.readyStatusLabel).toBe("Rune_AshBearAAAA"); + expect(viewModel.manifestSectionProps.nodes[0]?.indexLabel).toBe("1"); + expect(viewModel.manifestSectionProps.nodes.at(-1)?.isLast).toBe(true); + expect(viewModel.teachingSectionProps.features[0]).toMatchObject({ + isFeatured: true, + }); + expect(viewModel.teachingSectionProps.features[0]?.Icon).toBe(DoorOpen); + expect(viewModel.translationRailItems.at(-1)?.isLast).toBe(true); + }); +}); diff --git a/src/pages/home/lib/createHomePageViewModel.ts b/src/pages/home/lib/createHomePageViewModel.ts new file mode 100644 index 00000000..5c03a6d5 --- /dev/null +++ b/src/pages/home/lib/createHomePageViewModel.ts @@ -0,0 +1,147 @@ +import type { LucideIcon } from "lucide-react"; +import type { AuthContextValue } from "@/features/auth"; + +import { + HOME_COPY, + HOME_MANIFEST_FOCUS_ITEM, + HOME_MANIFEST_NODES, + HOME_TEACHING_FEATURES, + HOME_TEACHING_TONE_CLASS_NAMES, + HOME_TEACHING_TONES, + HOME_TRANSLATION_RAIL, + HOME_TRANSLATION_TONE_CLASS_NAMES, +} from "../config"; + +import { resolveHomeManifestToneClassName } from "./resolveHomeManifestToneClassName"; +import { resolveHomeTeachingIcon } from "./resolveHomeTeachingIcon"; + +type CreateHomePageViewModelInput = Pick< + AuthContextValue, + | "authStatus" + | "errorMessage" + | "handleSessionBootstrapRetry" + | "handleUsernameEntryRequest" + | "isAuthenticated" + | "readyStatusLabel" +>; + +export type HomeManifestNodeViewModel = { + description: string; + id: string; + indexLabel: string; + isLast: boolean; + title: string; + toneClassName: string; +}; + +export type HomeTeachingFeatureViewModel = { + description: string; + id: string; + Icon: LucideIcon; + iconClassName: string; + isFeatured: boolean; + isSealed: boolean; + className: string; + title: string; +}; + +export type HomeTranslationItemViewModel = { + label: string; + target: string; + isLast: boolean; + toneClassName: string; +}; + +type HomePageViewModel = { + heroProps: { + authStatus: AuthContextValue["authStatus"]; + errorMessage: string | null; + isAuthenticated: boolean; + onEntryRequest: () => void; + onRetry: () => void; + readyStatusLabel: string | null; + }; + manifestSectionProps: { + heading: string; + focusItem: { + badge: string; + description: string; + title: string; + toneClassName: string; + }; + nodes: readonly HomeManifestNodeViewModel[]; + subtitle: string; + }; + teachingSectionProps: { + features: readonly HomeTeachingFeatureViewModel[]; + heading: string; + }; + translationRailItems: readonly HomeTranslationItemViewModel[]; +}; + +export const createHomePageViewModel = ({ + authStatus, + errorMessage, + handleSessionBootstrapRetry, + handleUsernameEntryRequest, + isAuthenticated, + readyStatusLabel, +}: CreateHomePageViewModelInput): HomePageViewModel => { + const manifestSectionProps: HomePageViewModel["manifestSectionProps"] = { + focusItem: { + ...HOME_MANIFEST_FOCUS_ITEM, + toneClassName: resolveHomeManifestToneClassName( + HOME_MANIFEST_FOCUS_ITEM.tone, + ), + }, + heading: HOME_COPY.MANIFEST_PATH_HEADING, + nodes: HOME_MANIFEST_NODES.map((node, index) => ({ + description: node.description, + id: node.id, + indexLabel: String(index + 1), + isLast: index === HOME_MANIFEST_NODES.length - 1, + title: node.title, + toneClassName: resolveHomeManifestToneClassName(node.tone), + })), + subtitle: HOME_COPY.MANIFEST_PATH_SUBTITLE, + }; + + const teachingSectionProps: HomePageViewModel["teachingSectionProps"] = { + features: HOME_TEACHING_FEATURES.map((feature) => { + const Icon = resolveHomeTeachingIcon(feature.iconKey); + return { + className: + feature.isFeatured === true ? "h-full lg:col-span-2" : "h-full", + description: feature.description, + id: feature.id, + Icon, + iconClassName: HOME_TEACHING_TONE_CLASS_NAMES[feature.tone], + isFeatured: feature.isFeatured === true, + isSealed: feature.tone === HOME_TEACHING_TONES.SEALED, + title: feature.title, + }; + }), + heading: HOME_COPY.FEATURES_HEADING, + }; + + return { + heroProps: { + authStatus, + errorMessage, + isAuthenticated, + onEntryRequest: handleUsernameEntryRequest, + onRetry: handleSessionBootstrapRetry, + readyStatusLabel, + }, + manifestSectionProps, + teachingSectionProps, + translationRailItems: HOME_TRANSLATION_RAIL.map((item, index) => ({ + label: item.label, + isLast: index === HOME_TRANSLATION_RAIL.length - 1, + target: item.target, + toneClassName: HOME_TRANSLATION_TONE_CLASS_NAMES[item.tone], + })), + }; +}; + +export type { CreateHomePageViewModelInput, HomePageViewModel }; diff --git a/src/pages/home/lib/index.ts b/src/pages/home/lib/index.ts new file mode 100644 index 00000000..be92c5c7 --- /dev/null +++ b/src/pages/home/lib/index.ts @@ -0,0 +1,10 @@ +export { + type CreateHomePageViewModelInput, + createHomePageViewModel, + type HomeManifestNodeViewModel, + type HomePageViewModel, + type HomeTeachingFeatureViewModel, + type HomeTranslationItemViewModel, +} from "./createHomePageViewModel"; +export { resolveHomeManifestToneClassName } from "./resolveHomeManifestToneClassName"; +export { resolveHomeTeachingIcon } from "./resolveHomeTeachingIcon"; diff --git a/src/pages/home/lib/resolveHomeManifestToneClassName.test.ts b/src/pages/home/lib/resolveHomeManifestToneClassName.test.ts new file mode 100644 index 00000000..248eba99 --- /dev/null +++ b/src/pages/home/lib/resolveHomeManifestToneClassName.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { HOME_MANIFEST_TONES } from "../config"; +import { resolveHomeManifestToneClassName } from "./resolveHomeManifestToneClassName"; + +describe("resolveHomeManifestToneClassName", () => { + it("returns active classes", () => { + expect( + resolveHomeManifestToneClassName(HOME_MANIFEST_TONES.ACTIVE), + ).toContain("text-dungeon-gold"); + }); + + it("returns available classes", () => { + expect( + resolveHomeManifestToneClassName(HOME_MANIFEST_TONES.AVAILABLE), + ).toContain("text-dungeon-gold"); + }); + + it("returns sealed classes", () => { + expect( + resolveHomeManifestToneClassName(HOME_MANIFEST_TONES.SEALED), + ).toContain("text-dungeon-rune-sealed"); + }); +}); diff --git a/src/pages/home/lib/resolveHomeManifestToneClassName.ts b/src/pages/home/lib/resolveHomeManifestToneClassName.ts new file mode 100644 index 00000000..d8218a77 --- /dev/null +++ b/src/pages/home/lib/resolveHomeManifestToneClassName.ts @@ -0,0 +1,16 @@ +import { + HOME_MANIFEST_TONE_CLASS_NAMES, + HOME_MANIFEST_TONES, + type HomeManifestTone, +} from "../config"; + +export const resolveHomeManifestToneClassName = ( + tone: HomeManifestTone, +): string => { + switch (tone) { + case HOME_MANIFEST_TONES.ACTIVE: + case HOME_MANIFEST_TONES.AVAILABLE: + case HOME_MANIFEST_TONES.SEALED: + return HOME_MANIFEST_TONE_CLASS_NAMES[tone]; + } +}; diff --git a/src/pages/home/lib/resolveHomeTeachingIcon.test.ts b/src/pages/home/lib/resolveHomeTeachingIcon.test.ts new file mode 100644 index 00000000..cf502092 --- /dev/null +++ b/src/pages/home/lib/resolveHomeTeachingIcon.test.ts @@ -0,0 +1,19 @@ +import { DoorOpen, Lock, Package, Workflow } from "lucide-react"; +import { describe, expect, it } from "vitest"; +import { HOME_TEACHING_ICON_KEYS } from "../config"; +import { resolveHomeTeachingIcon } from "./resolveHomeTeachingIcon"; + +describe("resolveHomeTeachingIcon", () => { + it("resolves configured teaching icons", () => { + expect(resolveHomeTeachingIcon(HOME_TEACHING_ICON_KEYS.DOOR_OPEN)).toBe( + DoorOpen, + ); + expect(resolveHomeTeachingIcon(HOME_TEACHING_ICON_KEYS.LOCK)).toBe(Lock); + expect(resolveHomeTeachingIcon(HOME_TEACHING_ICON_KEYS.PACKAGE)).toBe( + Package, + ); + expect(resolveHomeTeachingIcon(HOME_TEACHING_ICON_KEYS.WORKFLOW)).toBe( + Workflow, + ); + }); +}); diff --git a/src/pages/home/lib/resolveHomeTeachingIcon.ts b/src/pages/home/lib/resolveHomeTeachingIcon.ts new file mode 100644 index 00000000..b4c1a6ae --- /dev/null +++ b/src/pages/home/lib/resolveHomeTeachingIcon.ts @@ -0,0 +1,27 @@ +import { + DoorOpen, + Lock, + type LucideIcon, + Package, + Workflow, + Zap, +} from "lucide-react"; + +import { HOME_TEACHING_ICON_KEYS, type HomeTeachingIconKey } from "../config"; + +export const resolveHomeTeachingIcon = ( + iconKey: HomeTeachingIconKey, +): LucideIcon => { + switch (iconKey) { + case HOME_TEACHING_ICON_KEYS.DOOR_OPEN: + return DoorOpen; + case HOME_TEACHING_ICON_KEYS.LOCK: + return Lock; + case HOME_TEACHING_ICON_KEYS.PACKAGE: + return Package; + case HOME_TEACHING_ICON_KEYS.WORKFLOW: + return Workflow; + case HOME_TEACHING_ICON_KEYS.ZAP: + return Zap; + } +}; diff --git a/src/pages/home/model/index.ts b/src/pages/home/model/index.ts new file mode 100644 index 00000000..4c00a62c --- /dev/null +++ b/src/pages/home/model/index.ts @@ -0,0 +1 @@ +export { useHomePage } from "./useHomePage"; diff --git a/src/pages/home/model/useHomePage.ts b/src/pages/home/model/useHomePage.ts new file mode 100644 index 00000000..646c17b3 --- /dev/null +++ b/src/pages/home/model/useHomePage.ts @@ -0,0 +1,23 @@ +import { useAuthContext } from "@/features/auth"; + +import { createHomePageViewModel } from "../lib"; + +export const useHomePage = () => { + const { + authStatus, + errorMessage, + handleSessionBootstrapRetry, + handleUsernameEntryRequest, + isAuthenticated, + readyStatusLabel, + } = useAuthContext(); + + return createHomePageViewModel({ + authStatus, + errorMessage, + handleSessionBootstrapRetry, + handleUsernameEntryRequest, + isAuthenticated, + readyStatusLabel, + }); +}; diff --git a/src/pages/home/ui/HomeBootstrapAuthenticatedStatusCard.tsx b/src/pages/home/ui/HomeBootstrapAuthenticatedStatusCard.tsx new file mode 100644 index 00000000..55299733 --- /dev/null +++ b/src/pages/home/ui/HomeBootstrapAuthenticatedStatusCard.tsx @@ -0,0 +1,32 @@ +import { CheckCircle2, ShieldCheck } from "lucide-react"; + +import { HOME_STATUS_COPY } from "../config"; + +import { HomeBootstrapStatusShell } from "./HomeBootstrapStatusShell"; + +type HomeBootstrapAuthenticatedStatusCardProps = { + readyStatusLabel: string | null; +}; + +export function HomeBootstrapAuthenticatedStatusCard({ + readyStatusLabel, +}: HomeBootstrapAuthenticatedStatusCardProps) { + return ( + } + title={HOME_STATUS_COPY.AUTHENTICATED.title} + > +
+ {readyStatusLabel ? ( + + + {readyStatusLabel} + + ) : null} + {HOME_STATUS_COPY.AUTHENTICATED.detail} +
+
+ ); +} diff --git a/src/pages/home/ui/HomeBootstrapCheckingStatusCard.tsx b/src/pages/home/ui/HomeBootstrapCheckingStatusCard.tsx new file mode 100644 index 00000000..24010365 --- /dev/null +++ b/src/pages/home/ui/HomeBootstrapCheckingStatusCard.tsx @@ -0,0 +1,25 @@ +import { LoaderCircle } from "lucide-react"; + +import { Skeleton } from "@/shared/ui"; + +import { HOME_STATUS_COPY } from "../config"; + +import { HomeBootstrapStatusShell } from "./HomeBootstrapStatusShell"; + +export function HomeBootstrapCheckingStatusCard() { + return ( + } + title={HOME_STATUS_COPY.CHECKING_SESSION.title} + > +
+ +

+ {HOME_STATUS_COPY.CHECKING_SESSION.detail} +

+
+
+ ); +} diff --git a/src/pages/home/ui/HomeBootstrapFailedStatusCard.tsx b/src/pages/home/ui/HomeBootstrapFailedStatusCard.tsx new file mode 100644 index 00000000..d71df87f --- /dev/null +++ b/src/pages/home/ui/HomeBootstrapFailedStatusCard.tsx @@ -0,0 +1,52 @@ +import { TriangleAlert } from "lucide-react"; + +import { + Alert, + AlertAction, + AlertDescription, + AlertTitle, + Badge, + Button, +} from "@/shared/ui"; + +import { HOME_STATUS_COPY } from "../config"; + +type HomeBootstrapFailedStatusCardProps = { + errorMessage: string | null; + onRetry: () => void; +}; + +export function HomeBootstrapFailedStatusCard({ + errorMessage, + onRetry, +}: HomeBootstrapFailedStatusCardProps) { + return ( + + +
+
+ + {HOME_STATUS_COPY.BOOTSTRAP_FAILED.badge} + + + {HOME_STATUS_COPY.BOOTSTRAP_FAILED.title} + +
+ + {errorMessage ?? HOME_STATUS_COPY.BOOTSTRAP_FAILED.description} + +

+ {HOME_STATUS_COPY.BOOTSTRAP_FAILED.detail} +

+
+ + + +
+ ); +} diff --git a/src/pages/home/ui/HomeBootstrapStatusCard.tsx b/src/pages/home/ui/HomeBootstrapStatusCard.tsx index 3aa442fc..ec982ded 100644 --- a/src/pages/home/ui/HomeBootstrapStatusCard.tsx +++ b/src/pages/home/ui/HomeBootstrapStatusCard.tsx @@ -1,23 +1,9 @@ -import { - CheckCircle2, - KeyRound, - LoaderCircle, - ShieldCheck, - TriangleAlert, -} from "lucide-react"; - import { AUTH_STATUS, type AuthStatus } from "@/features/auth"; -import { - Alert, - AlertAction, - AlertDescription, - AlertTitle, - Badge, - Button, - Skeleton, -} from "@/shared/ui"; -import { HOME_STATUS_COPY } from "../config"; +import { HomeBootstrapAuthenticatedStatusCard } from "./HomeBootstrapAuthenticatedStatusCard"; +import { HomeBootstrapCheckingStatusCard } from "./HomeBootstrapCheckingStatusCard"; +import { HomeBootstrapFailedStatusCard } from "./HomeBootstrapFailedStatusCard"; +import { HomeBootstrapUsernameStatusCard } from "./HomeBootstrapUsernameStatusCard"; type HomeBootstrapStatusCardProps = { authStatus: AuthStatus; @@ -33,160 +19,31 @@ export function HomeBootstrapStatusCard({ readyStatusLabel, }: HomeBootstrapStatusCardProps) { if (authStatus === AUTH_STATUS.CHECKING_SESSION) { - return ( -
-
-
- -
-
-
- - {HOME_STATUS_COPY.CHECKING_SESSION.badge} - -

- {HOME_STATUS_COPY.CHECKING_SESSION.title} -

-
-

- {HOME_STATUS_COPY.CHECKING_SESSION.description} -

-
- -

- {HOME_STATUS_COPY.CHECKING_SESSION.detail} -

-
-
-
-
- ); + return ; } if (authStatus === AUTH_STATUS.BOOTSTRAP_FAILED) { return ( - - -
-
- - {HOME_STATUS_COPY.BOOTSTRAP_FAILED.badge} - - - {HOME_STATUS_COPY.BOOTSTRAP_FAILED.title} - -
- - {errorMessage ?? HOME_STATUS_COPY.BOOTSTRAP_FAILED.description} - -

- {HOME_STATUS_COPY.BOOTSTRAP_FAILED.detail} -

-
- - - -
+ ); } if (authStatus === AUTH_STATUS.AUTHENTICATED) { return ( -
-
-
- -
-
-
- - {HOME_STATUS_COPY.AUTHENTICATED.badge} - -

- {HOME_STATUS_COPY.AUTHENTICATED.title} -

-
-

- {HOME_STATUS_COPY.AUTHENTICATED.description} -

-
- {readyStatusLabel ? ( - - - {readyStatusLabel} - - ) : null} - {HOME_STATUS_COPY.AUTHENTICATED.detail} -
-
-
-
+ ); } const isSubmittingUsername = authStatus === AUTH_STATUS.SUBMITTING_USERNAME; return ( -
-
-
- {isSubmittingUsername ? ( - - ) : ( - - )} -
-
-
- - {isSubmittingUsername - ? HOME_STATUS_COPY.SUBMITTING_USERNAME.badge - : HOME_STATUS_COPY.REQUIRES_USERNAME.badge} - -

- {isSubmittingUsername - ? HOME_STATUS_COPY.SUBMITTING_USERNAME.title - : HOME_STATUS_COPY.REQUIRES_USERNAME.title} -

-
-

- {isSubmittingUsername - ? HOME_STATUS_COPY.SUBMITTING_USERNAME.description - : HOME_STATUS_COPY.REQUIRES_USERNAME.description} -

-

- {isSubmittingUsername - ? HOME_STATUS_COPY.SUBMITTING_USERNAME.detail - : HOME_STATUS_COPY.REQUIRES_USERNAME.detail} -

-
-
-
+ ); } diff --git a/src/pages/home/ui/HomeBootstrapStatusShell.tsx b/src/pages/home/ui/HomeBootstrapStatusShell.tsx new file mode 100644 index 00000000..5bcad18a --- /dev/null +++ b/src/pages/home/ui/HomeBootstrapStatusShell.tsx @@ -0,0 +1,46 @@ +import type { ReactNode } from "react"; + +import { Badge } from "@/shared/ui"; + +type HomeBootstrapStatusShellProps = { + badgeLabel: string; + children: ReactNode; + description: string; + icon: ReactNode; + title: string; +}; + +export function HomeBootstrapStatusShell({ + badgeLabel, + children, + description, + icon, + title, +}: HomeBootstrapStatusShellProps) { + return ( +
+
+
+ {icon} +
+
+
+ + {badgeLabel} + +

{title}

+
+

{description}

+ {children} +
+
+
+ ); +} diff --git a/src/pages/home/ui/HomeBootstrapUsernameStatusCard.tsx b/src/pages/home/ui/HomeBootstrapUsernameStatusCard.tsx new file mode 100644 index 00000000..c6f7e897 --- /dev/null +++ b/src/pages/home/ui/HomeBootstrapUsernameStatusCard.tsx @@ -0,0 +1,46 @@ +import { KeyRound, LoaderCircle } from "lucide-react"; + +import { HOME_STATUS_COPY } from "../config"; + +import { HomeBootstrapStatusShell } from "./HomeBootstrapStatusShell"; + +type HomeBootstrapUsernameStatusCardProps = { + isSubmittingUsername: boolean; +}; + +export function HomeBootstrapUsernameStatusCard({ + isSubmittingUsername, +}: HomeBootstrapUsernameStatusCardProps) { + return ( + + ) : ( + + ) + } + title={ + isSubmittingUsername + ? HOME_STATUS_COPY.SUBMITTING_USERNAME.title + : HOME_STATUS_COPY.REQUIRES_USERNAME.title + } + > +

+ {isSubmittingUsername + ? HOME_STATUS_COPY.SUBMITTING_USERNAME.detail + : HOME_STATUS_COPY.REQUIRES_USERNAME.detail} +

+
+ ); +} diff --git a/src/pages/home/ui/HomeHeroSection.tsx b/src/pages/home/ui/HomeHeroSection.tsx new file mode 100644 index 00000000..de829897 --- /dev/null +++ b/src/pages/home/ui/HomeHeroSection.tsx @@ -0,0 +1,83 @@ +import { Link } from "@tanstack/react-router"; +import { DoorOpen } from "lucide-react"; + +import type { AuthStatus } from "@/features/auth"; +import { Badge, Button } from "@/shared/ui"; +import { MARKETING_ROUTES } from "@/widgets/marketing-shell"; + +import { HOME_COPY } from "../config"; + +import { HomeBootstrapStatusCard } from "./HomeBootstrapStatusCard"; + +type HomeHeroSectionProps = { + authStatus: AuthStatus; + errorMessage: string | null; + isAuthenticated: boolean; + onEntryRequest: () => void; + onRetry: () => void; + readyStatusLabel: string | null; +}; + +export function HomeHeroSection({ + authStatus, + errorMessage, + isAuthenticated, + onEntryRequest, + onRetry, + readyStatusLabel, +}: HomeHeroSectionProps) { + return ( +
+ + {HOME_COPY.BADGE} + + +
+

+ {HOME_COPY.HEADING} +

+

+ {HOME_COPY.SUBTITLE} +

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

+ {HOME_COPY.MOBILE_ORIENTATION_NOTICE} +

+
+
+
+ ); +} diff --git a/src/pages/home/ui/HomeManifestNode.tsx b/src/pages/home/ui/HomeManifestNode.tsx new file mode 100644 index 00000000..a3782b36 --- /dev/null +++ b/src/pages/home/ui/HomeManifestNode.tsx @@ -0,0 +1,30 @@ +import { cn } from "@/shared/lib"; + +import type { HomeManifestNodeViewModel } from "../lib"; + +type HomeManifestNodeProps = { + node: HomeManifestNodeViewModel; +}; + +export function HomeManifestNode({ node }: HomeManifestNodeProps) { + return ( +
  • +
    +
    + {node.indexLabel} +
    + {node.isLast ? null :
    } +
    + +
    +

    {node.title}

    +

    {node.description}

    +
    +
  • + ); +} diff --git a/src/pages/home/ui/HomeManifestSection.tsx b/src/pages/home/ui/HomeManifestSection.tsx new file mode 100644 index 00000000..4ea21853 --- /dev/null +++ b/src/pages/home/ui/HomeManifestSection.tsx @@ -0,0 +1,89 @@ +import { Badge } from "@/shared/ui"; + +import type { HomeManifestNodeViewModel } from "../lib"; + +import { HomeManifestNode } from "./HomeManifestNode"; + +type HomeManifestSectionProps = { + focusItem: { + badge: string; + description: string; + title: string; + toneClassName: string; + }; + heading: string; + nodes: readonly HomeManifestNodeViewModel[]; + subtitle: string; +}; + +export function HomeManifestSection({ + focusItem, + heading, + nodes, + subtitle, +}: HomeManifestSectionProps) { + return ( +
    +
    +

    + {heading} +

    +

    + {subtitle} +

    +
    + +
    +
    +
    + +
    +
    + ); +} diff --git a/src/pages/home/ui/HomePage.test.tsx b/src/pages/home/ui/HomePage.test.tsx index ee2c1e46..b2340d9e 100644 --- a/src/pages/home/ui/HomePage.test.tsx +++ b/src/pages/home/ui/HomePage.test.tsx @@ -1,6 +1,12 @@ // @vitest-environment happy-dom -import { cleanup, render, screen } from "@testing-library/react"; +import { + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -37,144 +43,102 @@ beforeEach(() => { mockUseAuthContext.mockReset(); }); +const createAuthContext = (isAuthenticated: boolean) => ({ + authStatus: isAuthenticated + ? AUTH_STATUS.AUTHENTICATED + : AUTH_STATUS.REQUIRES_USERNAME, + errorMessage: null, + handleSessionBootstrapRetry: vi.fn(), + handleUsernameEntryRequest: vi.fn(), + handleUsernameEntryDismiss: 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("Node Inspector")).not.toBeNull(); + expect(screen.getByText(HOME_COPY.FEATURES_HEADING)).not.toBeNull(); + expect(screen.getByText("State")).not.toBeNull(); + expect(screen.getByText("Room")).not.toBeNull(); + expect(screen.getByText("States become rooms")).not.toBeNull(); + expect(screen.getByText("Actors stay isolated")).not.toBeNull(); + expect(screen.getByText("What the dungeon teaches")).not.toBeNull(); + const teachingSection = screen + .getByRole("heading", { name: HOME_COPY.FEATURES_HEADING }) + .closest("section"); + expect(teachingSection).not.toBeNull(); + const firstTeachingCard = within( + teachingSection as HTMLElement, + ).getAllByRole("listitem")[0]; + expect(firstTeachingCard?.className).toContain("lg:col-span-2"); + expect(screen.queryByText(HOME_COPY.RUNTIME_HEADING)).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("requests username entry before authentication", () => { + const authContext = createAuthContext(false); + + mockUseAuthContext.mockReturnValue(authContext); render(); + fireEvent.click( + screen.getByRole("button", { + name: HOME_COPY.CTA_LABEL, + }), + ); + + expect(authContext.handleUsernameEntryRequest).toHaveBeenCalledOnce(); expect( screen.getByRole("link", { - name: HOME_COPY.CTA_LABEL, + name: HOME_COPY.TUTORIAL_LABEL, }), ).not.toBeNull(); + }); + + it("shows dungeon entry links after authentication", () => { + mockUseAuthContext.mockReturnValue(createAuthContext(true)); + + render(); + expect( - screen - .getByRole("link", { - name: HOME_COPY.CTA_LABEL, - }) - .getAttribute("data-variant"), - ).toBe("default"); - 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.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..0e21aae6 100644 --- a/src/pages/home/ui/HomePage.tsx +++ b/src/pages/home/ui/HomePage.tsx @@ -1,137 +1,26 @@ -import { Link } from "@tanstack/react-router"; -import { BookOpenText, DoorOpen, Sparkles } from "lucide-react"; - -import { - AUTH_ROUTE_PATHS, - UsernameModal, - useAuthContext, -} from "@/features/auth"; -import { Badge, Button, Card } from "@/shared/ui"; - -import { HOME_COPY, HOME_FEATURES } from "../config"; -import { HomeBootstrapStatusCard } from "./HomeBootstrapStatusCard"; +import { useHomePage } from "../model"; +import { HomeHeroSection } from "./HomeHeroSection"; +import { HomeManifestSection } from "./HomeManifestSection"; +import { HomeTeachingSection } from "./HomeTeachingSection"; +import { HomeTranslationRail } from "./HomeTranslationRail"; export function HomePage() { const { - authStatus, - errorMessage, - handleSessionBootstrapRetry, - handleUsernameFormSubmit, - isAuthenticated, - isUsernameModalOpen, - isUsernameSubmitting, - readyStatusLabel, - suggestedUsername, - } = useAuthContext(); + heroProps, + manifestSectionProps, + teachingSectionProps, + translationRailItems, + } = useHomePage(); return ( <> -
    -
    - -
    -
    -
    - - - {HOME_COPY.BADGE} - -
    - -
    -

    - {HOME_COPY.HEADING} -

    -

    - {HOME_COPY.SUBTITLE} -

    -

    - {HOME_COPY.SESSION_NOTE} -

    -
    + -
    - {isAuthenticated ? ( - - ) : ( - - )} - -
    -
    + - + -
    -
    -
    -
      - {HOME_FEATURES.map(({ detail, title }) => ( -
    • -
    • - ))} -
    -
    -
    -
    -
    -
    - + ); } diff --git a/src/pages/home/ui/HomeRuntimeReadingSection.tsx b/src/pages/home/ui/HomeRuntimeReadingSection.tsx new file mode 100644 index 00000000..589082b5 --- /dev/null +++ b/src/pages/home/ui/HomeRuntimeReadingSection.tsx @@ -0,0 +1,48 @@ +import { cn } from "@/shared/lib"; +import { MARKETING_LAYOUT_CLASS_NAMES } from "@/widgets/marketing-shell"; + +import { HOME_COPY, HOME_RUNTIME_PANELS } from "../config"; + +export function HomeRuntimeReadingSection() { + return ( +
    +
    +

    + {HOME_COPY.RUNTIME_HEADING} +

    +

    + {HOME_COPY.RUNTIME_SUBTITLE} +

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

      + {panel.title} +

      +

      + {panel.description} +

      +
      +
      +
    • + ))} +
    +
    + ); +} diff --git a/src/pages/home/ui/HomeTeachingItem.tsx b/src/pages/home/ui/HomeTeachingItem.tsx new file mode 100644 index 00000000..dc1c36f6 --- /dev/null +++ b/src/pages/home/ui/HomeTeachingItem.tsx @@ -0,0 +1,40 @@ +import { cn } from "@/shared/lib"; + +import type { HomeTeachingFeatureViewModel } from "../lib"; + +type HomeTeachingItemProps = { + feature: HomeTeachingFeatureViewModel; +}; + +export function HomeTeachingItem({ feature }: HomeTeachingItemProps) { + return ( +
    +
    +
    +
    + +
    +

    + {feature.title} +

    +

    + {feature.description} +

    +
    +
    +
    + ); +} diff --git a/src/pages/home/ui/HomeTeachingSection.tsx b/src/pages/home/ui/HomeTeachingSection.tsx new file mode 100644 index 00000000..80f87ef1 --- /dev/null +++ b/src/pages/home/ui/HomeTeachingSection.tsx @@ -0,0 +1,36 @@ +import type { HomeTeachingFeatureViewModel } from "../lib"; + +import { HomeTeachingItem } from "./HomeTeachingItem"; + +type HomeTeachingSectionProps = { + features: readonly HomeTeachingFeatureViewModel[]; + heading: string; +}; + +export function HomeTeachingSection({ + features, + heading, +}: HomeTeachingSectionProps) { + return ( +
    +
    +
    +

    + {heading} +

    +
    +
    + +
      + {features.map((feature) => ( +
    • + +
    • + ))} +
    +
    + ); +} diff --git a/src/pages/home/ui/HomeTranslationRail.tsx b/src/pages/home/ui/HomeTranslationRail.tsx new file mode 100644 index 00000000..383f2a45 --- /dev/null +++ b/src/pages/home/ui/HomeTranslationRail.tsx @@ -0,0 +1,50 @@ +import { ArrowRight } from "lucide-react"; + +import { cn } from "@/shared/lib"; +import { MARKETING_LAYOUT_CLASS_NAMES } from "@/widgets/marketing-shell"; + +import { HOME_TRANSLATION_ARROW_CLASS_NAME } from "../config"; +import type { HomeTranslationItemViewModel } from "../lib"; + +type HomeTranslationRailProps = { + items: readonly HomeTranslationItemViewModel[]; +}; + +export function HomeTranslationRail({ items }: HomeTranslationRailProps) { + return ( +
      + {items.map((item) => ( +
    • + + {item.label} + +
    • + ))} +
    + ); +} diff --git a/src/pages/tutorial/config/index.ts b/src/pages/tutorial/config/index.ts index aa5d996f..ec5db816 100644 --- a/src/pages/tutorial/config/index.ts +++ b/src/pages/tutorial/config/index.ts @@ -1,6 +1,24 @@ export { - TUTORIAL_CONTROLS, + TUTORIAL_CAMERA_MODES, + TUTORIAL_CONTROL_GROUPS, + TUTORIAL_CONTROL_TONES, + type TutorialControlTone, +} from "./tutorialControlConfig"; +export { + TUTORIAL_CONTROLS_COPY, TUTORIAL_COPY, - TUTORIAL_OBJECTIVES, - TUTORIAL_TIPS, + TUTORIAL_FIRST_RUN_COPY, + TUTORIAL_TAB_IDS, + TUTORIAL_TABS, + type TutorialTabId, } from "./tutorialCopy"; +export { + TUTORIAL_FIRST_RUN_STEPS, + TUTORIAL_FIRST_RUN_TONE_CLASS_NAMES, + TUTORIAL_FIRST_RUN_TONES, + type TutorialFirstRunTone, +} from "./tutorialFirstRunConfig"; +export { + TUTORIAL_ICON_KEYS, + type TutorialIconKey, +} from "./tutorialIconConfig"; diff --git a/src/pages/tutorial/config/tutorialControlConfig.ts b/src/pages/tutorial/config/tutorialControlConfig.ts new file mode 100644 index 00000000..49dfb290 --- /dev/null +++ b/src/pages/tutorial/config/tutorialControlConfig.ts @@ -0,0 +1,73 @@ +import { TUTORIAL_ICON_KEYS } from "./tutorialIconConfig"; + +export const TUTORIAL_CONTROL_TONES = { + ACCENT: "accent", + PRIMARY: "primary", +} as const; + +export type TutorialControlTone = + (typeof TUTORIAL_CONTROL_TONES)[keyof typeof TUTORIAL_CONTROL_TONES]; + +export const TUTORIAL_CONTROL_GROUPS = [ + { + heading: "Movement", + rows: [ + { + label: "Move", + mobileIconKey: TUTORIAL_ICON_KEYS.GAMEPAD, + mobileLabel: "Left joystick", + shortcuts: ["W", "A", "S", "D"], + }, + { + label: "Run toggle", + mobileIconKey: TUTORIAL_ICON_KEYS.FOOTPRINTS, + mobileLabel: "Footprints button", + shortcuts: ["Shift"], + }, + { + label: "Jump", + mobileIconKey: TUTORIAL_ICON_KEYS.CHEVRONS_UP, + mobileLabel: "Chevrons-up button", + shortcuts: ["Space"], + }, + ], + tone: TUTORIAL_CONTROL_TONES.PRIMARY, + }, + { + heading: "Actions", + rows: [ + { + label: "Interact", + shortcuts: ["E"], + }, + { + label: "Attack", + shortcuts: ["F"], + }, + ], + tone: TUTORIAL_CONTROL_TONES.ACCENT, + }, +] as const; + +export const TUTORIAL_CAMERA_MODES = [ + { + code: "3P", + key: "1", + label: "3rd Person", + }, + { + code: "TD", + key: "2", + label: "Top Down", + }, + { + code: "1P", + key: "3", + label: "1st Person", + }, + { + code: "FO", + key: "4", + label: "Free Orbital", + }, +] as const; diff --git a/src/pages/tutorial/config/tutorialCopy.ts b/src/pages/tutorial/config/tutorialCopy.ts index 6b88e88b..10cfd14b 100644 --- a/src/pages/tutorial/config/tutorialCopy.ts +++ b/src/pages/tutorial/config/tutorialCopy.ts @@ -1,68 +1,38 @@ +export const TUTORIAL_TAB_IDS = { + CONTROLS: "controls", + FIRST_RUN: "first-run", +} as const; + +export type TutorialTabId = + (typeof TUTORIAL_TAB_IDS)[keyof typeof TUTORIAL_TAB_IDS]; + export const TUTORIAL_COPY = { - BADGE: "Quick guide", - HEADING: "How to Play", - SUBTITLE: "Learn the basics, then step into the dungeon when you’re ready.", - CONTROLS_HEADING: "Controls", - CONTROLS_DESCRIPTION: - "The essentials: move, interact, attack, and change the camera.", - OBJECTIVES_HEADING: "Your first run", - OBJECTIVES_DESCRIPTION: - "Explore the entrance, find the key, and reach the exit.", - TIPS_HEADING: "Tips", - TIPS_DESCRIPTION: "A few small reminders that keep the dungeon easy to read.", CTA_LABEL: "Enter Dungeon", - HOME_LABEL: "Back to Home", + HEADING: "Manual", + SUBTITLE: + "Navigate the functional structure of the playable architecture. Understand state transitions, entity data, and traversal paths.", } as const; -export const TUTORIAL_CONTROLS = [ - { - detail: "Use WASD or the arrow keys to walk through corridors and rooms.", - keyLabel: "WASD / ARROWS", - label: "Move through the dungeon", - }, +export const TUTORIAL_TABS = [ { - detail: "Press F to open doors and use nearby objects.", - keyLabel: "F", - label: "Interact with prompts", + id: TUTORIAL_TAB_IDS.CONTROLS, + label: "Controls", }, { - detail: "Press E when enemies are in range.", - keyLabel: "E", - label: "Attack nearby foes", - }, - { - detail: "Use 1-4 to switch between the camera views.", - keyLabel: "1-4", - label: "Switch camera modes", + id: TUTORIAL_TAB_IDS.FIRST_RUN, + label: "First Run", }, ] as const; -export const TUTORIAL_OBJECTIVES = [ - { - step: 1, - label: "Explore the entrance", - detail: "Walk through the first rooms and get your bearings.", - }, - { - step: 2, - label: "Find the key", - detail: "Pick up the Treasure Key when you spot it.", - }, - { - step: 3, - label: "Open the path forward", - detail: "Use the key to unlock the next door.", - }, - { - step: 4, - label: "Clear the guard room", - detail: "Defeat the enemies and finish the floor.", - }, -] as const; +export const TUTORIAL_CONTROLS_COPY = { + SECTION_HEADING: "Controls", + SECTION_DESCRIPTION: + "Master the tactile interface to navigate the architecture.", + MOVEMENT_HEADING: "Movement", + ACTIONS_HEADING: "Actions", + CAMERA_HEADING: "Camera modes", +} as const; -export const TUTORIAL_TIPS = [ - "Try the different camera views to find the one that feels easiest.", - "Watch your HP in the HUD; enemies deal damage on contact.", - "Achievements unlock automatically as you complete objectives.", - "The dungeon resets when you restart, giving you a fresh run.", -] as const; +export const TUTORIAL_FIRST_RUN_COPY = { + SECTION_HEADING: "First run path", +} as const; diff --git a/src/pages/tutorial/config/tutorialFirstRunConfig.ts b/src/pages/tutorial/config/tutorialFirstRunConfig.ts new file mode 100644 index 00000000..840de582 --- /dev/null +++ b/src/pages/tutorial/config/tutorialFirstRunConfig.ts @@ -0,0 +1,78 @@ +export const TUTORIAL_FIRST_RUN_TONES = { + ACCENT: "accent", + PRIMARY: "primary", + SEALED: "sealed", + SUCCESS: "success", +} as const; + +export type TutorialFirstRunTone = + (typeof TUTORIAL_FIRST_RUN_TONES)[keyof typeof TUTORIAL_FIRST_RUN_TONES]; + +export const TUTORIAL_FIRST_RUN_TONE_CLASS_NAMES = { + [TUTORIAL_FIRST_RUN_TONES.ACCENT]: "text-dungeon-gold", + [TUTORIAL_FIRST_RUN_TONES.PRIMARY]: "text-dungeon-gold", + [TUTORIAL_FIRST_RUN_TONES.SEALED]: "text-dungeon-rune-sealed", + [TUTORIAL_FIRST_RUN_TONES.SUCCESS]: "text-success", +} as const; + +export const TUTORIAL_FIRST_RUN_STEPS = [ + { + detail: + "Mount the run at Entrance, hydrate the active room, and publish the initial state snapshot.", + label: "Enter the initial state", + tokens: [ + { + label: "ENTRANCE", + tone: TUTORIAL_FIRST_RUN_TONES.PRIMARY, + }, + ], + }, + { + detail: + "Emit a movement event; the corridor transition resolves before the next room becomes active.", + label: "Dispatch movement events", + tokens: [ + { + label: "CORRIDORS", + tone: TUTORIAL_FIRST_RUN_TONES.ACCENT, + }, + ], + }, + { + detail: + "Evaluate the guard against keys and blockers; the path stays sealed until the predicate passes.", + label: "Satisfy guards", + tokens: [ + { + label: "KEYS / BLOCKERS", + tone: TUTORIAL_FIRST_RUN_TONES.SEALED, + }, + ], + }, + { + detail: + "Mutate combat state and inventory, then recalculate which transitions remain valid.", + label: "Resolve combat and context", + tokens: [ + { + label: "ENEMY STATE", + tone: TUTORIAL_FIRST_RUN_TONES.ACCENT, + }, + { + label: "INVENTORY", + tone: TUTORIAL_FIRST_RUN_TONES.PRIMARY, + }, + ], + }, + { + detail: + "Write the terminal floor state and close the run once the final room resolves.", + label: "Reach the final state", + tokens: [ + { + label: "COMPLETE FLOOR", + tone: TUTORIAL_FIRST_RUN_TONES.SUCCESS, + }, + ], + }, +] as const; diff --git a/src/pages/tutorial/config/tutorialIconConfig.ts b/src/pages/tutorial/config/tutorialIconConfig.ts new file mode 100644 index 00000000..71154b8a --- /dev/null +++ b/src/pages/tutorial/config/tutorialIconConfig.ts @@ -0,0 +1,8 @@ +export const TUTORIAL_ICON_KEYS = { + CHEVRONS_UP: "chevrons-up", + FOOTPRINTS: "footprints", + GAMEPAD: "gamepad", +} as const; + +export type TutorialIconKey = + (typeof TUTORIAL_ICON_KEYS)[keyof typeof TUTORIAL_ICON_KEYS]; diff --git a/src/pages/tutorial/index.ts b/src/pages/tutorial/index.ts index 2970712e..b1baa62e 100644 --- a/src/pages/tutorial/index.ts +++ b/src/pages/tutorial/index.ts @@ -1,7 +1,13 @@ export { - TUTORIAL_CONTROLS, + TUTORIAL_CAMERA_MODES, + TUTORIAL_CONTROL_GROUPS, + TUTORIAL_CONTROLS_COPY, TUTORIAL_COPY, - TUTORIAL_OBJECTIVES, - TUTORIAL_TIPS, + TUTORIAL_FIRST_RUN_COPY, + TUTORIAL_FIRST_RUN_STEPS, + TUTORIAL_FIRST_RUN_TONE_CLASS_NAMES, + TUTORIAL_TAB_IDS, + TUTORIAL_TABS, + type TutorialTabId, } from "./config"; export { TutorialPage } from "./ui"; diff --git a/src/pages/tutorial/lib/createTutorialPageViewModel.test.ts b/src/pages/tutorial/lib/createTutorialPageViewModel.test.ts new file mode 100644 index 00000000..dce2606b --- /dev/null +++ b/src/pages/tutorial/lib/createTutorialPageViewModel.test.ts @@ -0,0 +1,30 @@ +import { ChevronsUp, Footprints, Gamepad2 } from "lucide-react"; +import { describe, expect, it, vi } from "vitest"; + +import { createTutorialPageViewModel } from "./createTutorialPageViewModel"; + +describe("createTutorialPageViewModel", () => { + it("maps auth state into hero props and control view models", () => { + const handleUsernameEntryRequest = vi.fn(); + + const viewModel = createTutorialPageViewModel({ + handleUsernameEntryRequest, + isAuthenticated: false, + }); + + expect(viewModel.heroProps.isAuthenticated).toBe(false); + expect(viewModel.heroProps.onEntryRequest).toBe(handleUsernameEntryRequest); + expect( + viewModel.controlsSectionProps.controlGroups[0]?.rows[0]?.mobileIcon, + ).toBe(Gamepad2); + expect( + viewModel.controlsSectionProps.controlGroups[0]?.rows[1]?.mobileIcon, + ).toBe(Footprints); + expect( + viewModel.controlsSectionProps.controlGroups[0]?.rows[2]?.mobileIcon, + ).toBe(ChevronsUp); + expect(viewModel.firstRunSectionProps.steps[0]?.label).toBe( + "Enter the initial state", + ); + }); +}); diff --git a/src/pages/tutorial/lib/createTutorialPageViewModel.ts b/src/pages/tutorial/lib/createTutorialPageViewModel.ts new file mode 100644 index 00000000..ffab28d8 --- /dev/null +++ b/src/pages/tutorial/lib/createTutorialPageViewModel.ts @@ -0,0 +1,85 @@ +import type { AuthContextValue } from "@/features/auth"; + +import { + TUTORIAL_CAMERA_MODES, + TUTORIAL_CONTROL_GROUPS, + TUTORIAL_CONTROLS_COPY, + TUTORIAL_FIRST_RUN_COPY, + TUTORIAL_FIRST_RUN_STEPS, + type TutorialControlTone, +} from "../config"; + +import { resolveTutorialIcon } from "./resolveTutorialIcon"; + +type CreateTutorialPageViewModelInput = Pick< + AuthContextValue, + "handleUsernameEntryRequest" | "isAuthenticated" +>; + +export type TutorialControlRowViewModel = { + label: string; + mobileIcon: ReturnType | null; + mobileLabel?: string; + shortcuts: readonly string[]; +}; + +export type TutorialControlGroupViewModel = { + heading: string; + rows: readonly TutorialControlRowViewModel[]; + tone: TutorialControlTone; +}; + +type TutorialPageViewModel = { + controlsSectionProps: { + description: string; + cameraModes: typeof TUTORIAL_CAMERA_MODES; + controlGroups: readonly TutorialControlGroupViewModel[]; + heading: string; + cameraHeading: string; + }; + firstRunSectionProps: { + heading: string; + steps: typeof TUTORIAL_FIRST_RUN_STEPS; + }; + heroProps: { + isAuthenticated: boolean; + onEntryRequest: () => void; + }; +}; + +export const createTutorialPageViewModel = ({ + handleUsernameEntryRequest, + isAuthenticated, +}: CreateTutorialPageViewModelInput): TutorialPageViewModel => { + const controlGroups = TUTORIAL_CONTROL_GROUPS.map((group) => ({ + heading: group.heading, + rows: group.rows.map((row) => ({ + label: row.label, + mobileIcon: + "mobileIconKey" in row ? resolveTutorialIcon(row.mobileIconKey) : null, + mobileLabel: "mobileLabel" in row ? row.mobileLabel : undefined, + shortcuts: row.shortcuts, + })), + tone: group.tone, + })); + + return { + controlsSectionProps: { + cameraModes: TUTORIAL_CAMERA_MODES, + cameraHeading: TUTORIAL_CONTROLS_COPY.CAMERA_HEADING, + controlGroups, + description: TUTORIAL_CONTROLS_COPY.SECTION_DESCRIPTION, + heading: TUTORIAL_CONTROLS_COPY.SECTION_HEADING, + }, + firstRunSectionProps: { + heading: TUTORIAL_FIRST_RUN_COPY.SECTION_HEADING, + steps: TUTORIAL_FIRST_RUN_STEPS, + }, + heroProps: { + isAuthenticated, + onEntryRequest: handleUsernameEntryRequest, + }, + }; +}; + +export type { CreateTutorialPageViewModelInput, TutorialPageViewModel }; diff --git a/src/pages/tutorial/lib/index.ts b/src/pages/tutorial/lib/index.ts new file mode 100644 index 00000000..a84e0abf --- /dev/null +++ b/src/pages/tutorial/lib/index.ts @@ -0,0 +1,8 @@ +export { + type CreateTutorialPageViewModelInput, + createTutorialPageViewModel, + type TutorialControlGroupViewModel, + type TutorialControlRowViewModel, + type TutorialPageViewModel, +} from "./createTutorialPageViewModel"; +export { resolveTutorialIcon } from "./resolveTutorialIcon"; diff --git a/src/pages/tutorial/lib/resolveTutorialIcon.test.ts b/src/pages/tutorial/lib/resolveTutorialIcon.test.ts new file mode 100644 index 00000000..6ce9a045 --- /dev/null +++ b/src/pages/tutorial/lib/resolveTutorialIcon.test.ts @@ -0,0 +1,15 @@ +import { ChevronsUp, Footprints, Gamepad2 } from "lucide-react"; +import { describe, expect, it } from "vitest"; + +import { TUTORIAL_ICON_KEYS } from "../config"; +import { resolveTutorialIcon } from "./resolveTutorialIcon"; + +describe("resolveTutorialIcon", () => { + it("resolves configured tutorial icons", () => { + expect(resolveTutorialIcon(TUTORIAL_ICON_KEYS.GAMEPAD)).toBe(Gamepad2); + expect(resolveTutorialIcon(TUTORIAL_ICON_KEYS.FOOTPRINTS)).toBe(Footprints); + expect(resolveTutorialIcon(TUTORIAL_ICON_KEYS.CHEVRONS_UP)).toBe( + ChevronsUp, + ); + }); +}); diff --git a/src/pages/tutorial/lib/resolveTutorialIcon.ts b/src/pages/tutorial/lib/resolveTutorialIcon.ts new file mode 100644 index 00000000..3190d738 --- /dev/null +++ b/src/pages/tutorial/lib/resolveTutorialIcon.ts @@ -0,0 +1,19 @@ +import { + ChevronsUp, + Footprints, + Gamepad2, + type LucideIcon, +} from "lucide-react"; + +import { TUTORIAL_ICON_KEYS, type TutorialIconKey } from "../config"; + +export const resolveTutorialIcon = (iconKey: TutorialIconKey): LucideIcon => { + switch (iconKey) { + case TUTORIAL_ICON_KEYS.CHEVRONS_UP: + return ChevronsUp; + case TUTORIAL_ICON_KEYS.FOOTPRINTS: + return Footprints; + case TUTORIAL_ICON_KEYS.GAMEPAD: + return Gamepad2; + } +}; diff --git a/src/pages/tutorial/model/index.ts b/src/pages/tutorial/model/index.ts index cb0ff5c3..85e0cac5 100644 --- a/src/pages/tutorial/model/index.ts +++ b/src/pages/tutorial/model/index.ts @@ -1 +1 @@ -export {}; +export { useTutorialPage } from "./useTutorialPage"; diff --git a/src/pages/tutorial/model/useTutorialPage.ts b/src/pages/tutorial/model/useTutorialPage.ts new file mode 100644 index 00000000..ad146c67 --- /dev/null +++ b/src/pages/tutorial/model/useTutorialPage.ts @@ -0,0 +1,12 @@ +import { useAuthContext } from "@/features/auth"; + +import { createTutorialPageViewModel } from "../lib"; + +export const useTutorialPage = () => { + const { handleUsernameEntryRequest, isAuthenticated } = useAuthContext(); + + return createTutorialPageViewModel({ + handleUsernameEntryRequest, + isAuthenticated, + }); +}; diff --git a/src/pages/tutorial/ui/TutorialCameraModesCard.tsx b/src/pages/tutorial/ui/TutorialCameraModesCard.tsx new file mode 100644 index 00000000..bb888251 --- /dev/null +++ b/src/pages/tutorial/ui/TutorialCameraModesCard.tsx @@ -0,0 +1,59 @@ +import { Camera } from "lucide-react"; + +import { cn } from "@/shared/lib"; +import { Card, CardContent } from "@/shared/ui"; + +import type { TutorialPageViewModel } from "../lib"; + +type TutorialCameraModesCardProps = { + cameraHeading: TutorialPageViewModel["controlsSectionProps"]["cameraHeading"]; + cameraModes: TutorialPageViewModel["controlsSectionProps"]["cameraModes"]; +}; + +export function TutorialCameraModesCard({ + cameraHeading, + cameraModes, +}: TutorialCameraModesCardProps) { + return ( + + +
    +
    + +
    +

    + {cameraHeading} +

    +
    + +
      + {cameraModes.map((mode) => ( +
    • +
      + + {mode.label} + +
      + + {mode.code} + + + {mode.key} + +
      +
      +
    • + ))} +
    +
    +
    + ); +} diff --git a/src/pages/tutorial/ui/TutorialControlGroupCard.tsx b/src/pages/tutorial/ui/TutorialControlGroupCard.tsx new file mode 100644 index 00000000..3341284c --- /dev/null +++ b/src/pages/tutorial/ui/TutorialControlGroupCard.tsx @@ -0,0 +1,52 @@ +import { Keyboard, Zap } from "lucide-react"; + +import { cn } from "@/shared/lib"; +import { Card, CardContent } from "@/shared/ui"; + +import { TUTORIAL_CONTROL_TONES } from "../config"; +import type { TutorialControlGroupViewModel } from "../lib"; + +import { TutorialControlRow } from "./TutorialControlRow"; + +type TutorialControlGroupCardProps = { + group: TutorialControlGroupViewModel; +}; + +export function TutorialControlGroupCard({ + group, +}: TutorialControlGroupCardProps) { + return ( + + +
    +
    + {group.tone === TUTORIAL_CONTROL_TONES.ACCENT ? ( + + ) : ( + + )} +
    +

    + {group.heading} +

    +
    + +
      + {group.rows.map((row) => ( + + ))} +
    +
    +
    + ); +} diff --git a/src/pages/tutorial/ui/TutorialControlRow.tsx b/src/pages/tutorial/ui/TutorialControlRow.tsx new file mode 100644 index 00000000..7e8455b1 --- /dev/null +++ b/src/pages/tutorial/ui/TutorialControlRow.tsx @@ -0,0 +1,60 @@ +import type { LucideIcon } from "lucide-react"; + +import { cn } from "@/shared/lib"; + +type TutorialControlRowData = { + label: string; + mobileIcon?: LucideIcon | null; + mobileLabel?: string; + shortcuts: readonly string[]; +}; + +type TutorialControlRowProps = { + row: TutorialControlRowData; +}; + +export function TutorialControlRow({ row }: TutorialControlRowProps) { + const MobileShortcutIcon = row.mobileIcon; + const hasMobileIcon = MobileShortcutIcon != null; + + return ( +
  • + {row.label} +
    + {hasMobileIcon && MobileShortcutIcon ? ( +
    +
    +
    + + {row.mobileLabel} + +
    + ) : null} + +
    + {row.shortcuts.map((shortcut) => ( + + {shortcut} + + ))} +
    +
    +
  • + ); +} + +export type { TutorialControlRowData }; diff --git a/src/pages/tutorial/ui/TutorialControlsCard.tsx b/src/pages/tutorial/ui/TutorialControlsCard.tsx deleted file mode 100644 index 555ac7a4..00000000 --- a/src/pages/tutorial/ui/TutorialControlsCard.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Keyboard } from "lucide-react"; - -import { Badge } from "@/shared/ui"; - -import { TUTORIAL_CONTROLS, TUTORIAL_COPY } from "../config"; - -export function TutorialControlsCard() { - return ( -
    -
    -
    - -
    -
    -

    - {TUTORIAL_COPY.CONTROLS_HEADING} -

    -

    - {TUTORIAL_COPY.CONTROLS_DESCRIPTION} -

    -
    -
    - -
    - {TUTORIAL_CONTROLS.map(({ detail, keyLabel, label }) => ( -
    -
    -
    {label}
    -
    {detail}
    -
    - - {keyLabel} - -
    - ))} -
    -
    - ); -} diff --git a/src/pages/tutorial/ui/TutorialControlsPanel.tsx b/src/pages/tutorial/ui/TutorialControlsPanel.tsx new file mode 100644 index 00000000..33e53597 --- /dev/null +++ b/src/pages/tutorial/ui/TutorialControlsPanel.tsx @@ -0,0 +1,46 @@ +import type { TutorialPageViewModel } from "../lib"; + +import { TutorialCameraModesCard } from "./TutorialCameraModesCard"; +import { TutorialControlGroupCard } from "./TutorialControlGroupCard"; + +type TutorialControlsPanelProps = TutorialPageViewModel["controlsSectionProps"]; + +export function TutorialControlsPanel({ + cameraHeading, + cameraModes, + controlGroups, + description, + heading, +}: TutorialControlsPanelProps) { + return ( +
    +
    +

    + {heading} +

    +

    + {description} +

    +
    + +
    +
    + {controlGroups.map((group) => ( + + ))} +
    + + +
    +
    + ); +} diff --git a/src/pages/tutorial/ui/TutorialFirstRunPanel.tsx b/src/pages/tutorial/ui/TutorialFirstRunPanel.tsx new file mode 100644 index 00000000..cb43918c --- /dev/null +++ b/src/pages/tutorial/ui/TutorialFirstRunPanel.tsx @@ -0,0 +1,85 @@ +import { cn } from "@/shared/lib"; +import { Badge } from "@/shared/ui"; + +import { + TUTORIAL_FIRST_RUN_TONE_CLASS_NAMES, + TUTORIAL_FIRST_RUN_TONES, +} from "../config"; +import type { TutorialPageViewModel } from "../lib"; + +type TutorialFirstRunPanelProps = TutorialPageViewModel["firstRunSectionProps"]; + +export function TutorialFirstRunPanel({ + heading, + steps, +}: TutorialFirstRunPanelProps) { + return ( +
    +
    +

    + {heading} +

    +
    +
    + +
      + {steps.map((step, index) => ( +
    1. +
      +
      + {String(index + 1).padStart(2, "0")} +
      + {index < steps.length - 1 ? ( +
      + ) : null} +
      + +
      +
      +
      +

      + {step.label} +

      +
      + {step.tokens.map((token) => ( + + {token.label} + + ))} +
      +

      + {step.detail} +

      +
      +
      +
    2. + ))} +
    +
    + ); +} diff --git a/src/pages/tutorial/ui/TutorialHero.tsx b/src/pages/tutorial/ui/TutorialHero.tsx new file mode 100644 index 00000000..048cc92a --- /dev/null +++ b/src/pages/tutorial/ui/TutorialHero.tsx @@ -0,0 +1,45 @@ +import { Link } from "@tanstack/react-router"; +import { BookOpen, DoorOpen } from "lucide-react"; + +import { Button } from "@/shared/ui"; +import { MARKETING_ROUTES } from "@/widgets/marketing-shell"; + +import { TUTORIAL_COPY } from "../config"; + +type TutorialHeroProps = { + isAuthenticated: boolean; + onEntryRequest: () => void; +}; + +export function TutorialHero({ + isAuthenticated, + onEntryRequest, +}: TutorialHeroProps) { + return ( +
    +
    + +

    + {TUTORIAL_COPY.HEADING} +

    +
    +

    + {TUTORIAL_COPY.SUBTITLE} +

    + + {isAuthenticated ? ( + + ) : ( + + )} +
    + ); +} 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..b7a78939 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,125 @@ vi.mock("@tanstack/react-router", () => ({ afterEach(cleanup); beforeEach(() => { - mockUseAuthContext.mockReset(); + mockUseAuthContext.mockReturnValue({ + handleUsernameEntryRequest: vi.fn(), + 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("Manual")).not.toBeNull(); + expect(screen.getByRole("tab", { name: "Controls" })).not.toBeNull(); + expect(screen.getByRole("tab", { name: "First Run" })).not.toBeNull(); + expect(screen.queryByRole("tab", { name: "Concepts" })).toBeNull(); + }); + + it("renders accurate desktop controls", () => { render(); - const dungeonEntryButton = screen.getByRole("button", { - name: TUTORIAL_COPY.CTA_LABEL, - }); + expect( + screen.getByText( + "Master the tactile interface to navigate the architecture.", + ), + ).not.toBeNull(); + expect(screen.getByText("Movement")).not.toBeNull(); + expect(screen.getByText("Actions")).not.toBeNull(); + expect(screen.getByText("Camera modes")).not.toBeNull(); + expect(screen.getByText("3rd Person")).not.toBeNull(); + expect(screen.getByText("Top Down")).not.toBeNull(); + expect(screen.getByText("1st Person")).not.toBeNull(); + expect(screen.getByText("Free Orbital")).not.toBeNull(); + expect(screen.getByText("W", { selector: "kbd" })).not.toBeNull(); + expect(screen.getByText("Shift", { selector: "kbd" })).not.toBeNull(); + expect(screen.getByText("Space", { selector: "kbd" })).not.toBeNull(); + expect(screen.getByText("E", { selector: "kbd" })).not.toBeNull(); + expect(screen.getByText("F", { selector: "kbd" })).not.toBeNull(); + expect(screen.getByText("Left joystick")).not.toBeNull(); + expect(screen.getByText("Footprints button")).not.toBeNull(); + expect(screen.getByText("Chevrons-up button")).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.getByText("Dispatch movement events")).not.toBeNull(); + expect(screen.getByText("Satisfy guards")).not.toBeNull(); + expect(screen.getByText("Resolve combat and context")).not.toBeNull(); + expect(screen.getByText("Reach the final state")).not.toBeNull(); + expect( + screen.getByText( + "Mount the run at Entrance, hydrate the active room, and publish the initial state snapshot.", + ), + ).not.toBeNull(); + expect( + screen.getByText( + "Emit a movement event; the corridor transition resolves before the next room becomes active.", + ), + ).not.toBeNull(); expect( - screen.getByRole("link", { - name: TUTORIAL_COPY.HOME_LABEL, - }), + screen.getByText( + "Evaluate the guard against keys and blockers; the path stays sealed until the predicate passes.", + ), ).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"); + screen.getByText( + "Mutate combat state and inventory, then recalculate which transitions remain valid.", + ), + ).not.toBeNull(); + expect( + screen.getByText( + "Write the terminal floor state and close the run once the final room resolves.", + ), + ).not.toBeNull(); + expect(screen.getByText("ENTRANCE")).not.toBeNull(); + expect(screen.getByText("CORRIDORS")).not.toBeNull(); + expect(screen.getByText("KEYS / BLOCKERS")).not.toBeNull(); + expect(screen.getByText("ENEMY STATE")).not.toBeNull(); + expect(screen.getByText("INVENTORY")).not.toBeNull(); + expect(screen.getByText("COMPLETE FLOOR")).not.toBeNull(); + expect(screen.queryByText(/git clone/i)).toBeNull(); + expect(screen.queryByText(/pnpm/i)).toBeNull(); + expect(screen.queryByText(/deployment/i)).toBeNull(); }); - it("keeps the dungeon entry CTA in the hero when authenticated", () => { + it("requests username entry when unauthenticated", () => { + const handleUsernameEntryRequest = vi.fn(); + 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", + handleUsernameEntryRequest, + isAuthenticated: false, }); render(); const hero = screen - .getByRole("heading", { name: TUTORIAL_COPY.HEADING }) + .getByRole("heading", { name: "Manual" }) .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", + }); + + fireEvent.click(entryButton); + + expect(handleUsernameEntryRequest).toHaveBeenCalledOnce(); }); }); diff --git a/src/pages/tutorial/ui/TutorialPage.tsx b/src/pages/tutorial/ui/TutorialPage.tsx index cd1c6297..af8df724 100644 --- a/src/pages/tutorial/ui/TutorialPage.tsx +++ b/src/pages/tutorial/ui/TutorialPage.tsx @@ -1,76 +1,41 @@ -import { Link } from "@tanstack/react-router"; -import { BookOpenText, DoorOpen, House } from "lucide-react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui"; -import { AUTH_ROUTE_PATHS, useAuthContext } from "@/features/auth"; -import { Badge, Button, Card } from "@/shared/ui"; +import { TUTORIAL_TAB_IDS, TUTORIAL_TABS } from "../config"; +import { useTutorialPage } from "../model"; -import { TUTORIAL_COPY } from "../config"; -import { TutorialControlsCard } from "./TutorialControlsCard"; -import { TutorialObjectivesCard } from "./TutorialObjectivesCard"; -import { TutorialTipsCard } from "./TutorialTipsCard"; +import { TutorialControlsPanel } from "./TutorialControlsPanel"; +import { TutorialFirstRunPanel } from "./TutorialFirstRunPanel"; +import { TutorialHero } from "./TutorialHero"; export function TutorialPage() { - const { isAuthenticated } = useAuthContext(); + const { controlsSectionProps, firstRunSectionProps, heroProps } = + useTutorialPage(); return ( -
    -
    - -
    -
    -
    - - - {TUTORIAL_COPY.BADGE} - -
    + <> + -
    -

    - {TUTORIAL_COPY.HEADING} -

    -

    - {TUTORIAL_COPY.SUBTITLE} -

    -
    + + + {TUTORIAL_TABS.map((tab) => ( + + {tab.label} + + ))} + -
    - {isAuthenticated ? ( - - ) : ( - - )} - -
    -
    + + + -
    - - - -
    -
    -
    -
    -
    + + + + + ); } 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/assets/runestone-mark.png b/src/widgets/marketing-shell/assets/runestone-mark.png new file mode 100644 index 00000000..cb4dee5b Binary files /dev/null and b/src/widgets/marketing-shell/assets/runestone-mark.png differ diff --git a/src/widgets/marketing-shell/config/index.ts b/src/widgets/marketing-shell/config/index.ts new file mode 100644 index 00000000..308fa42e --- /dev/null +++ b/src/widgets/marketing-shell/config/index.ts @@ -0,0 +1,18 @@ +export { MARKETING_LAYOUT_CLASS_NAMES } from "./marketingLayoutConfig"; + +export { + RUNESTONE_LOGO_SEGMENT_CLASS_NAMES, + RUNESTONE_LOGO_SEGMENT_IDS, + RUNESTONE_LOGO_VARIANTS, + type RunestoneLogoSegmentId, + type RunestoneLogoVariant, +} from "./marketingLogoConfig"; + +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/marketingLayoutConfig.ts b/src/widgets/marketing-shell/config/marketingLayoutConfig.ts new file mode 100644 index 00000000..2a9d905f --- /dev/null +++ b/src/widgets/marketing-shell/config/marketingLayoutConfig.ts @@ -0,0 +1,39 @@ +export const MARKETING_LAYOUT_CLASS_NAMES = { + PAGE_FRAME: + "mx-auto flex w-full max-w-7xl flex-col gap-12 px-4 py-10 sm:gap-14 sm:px-6 sm:py-14 lg:gap-16 lg:px-8 lg:py-16", + CONTENT_WIDTH: "max-w-7xl", + SHELL_BACKGROUND: + "pointer-events-none absolute inset-0 overflow-hidden bg-background", + SHELL_GLOW_PRIMARY: + "absolute -left-24 -top-24 size-96 rounded-full bg-dungeon-gold/10 blur-3xl", + SHELL_GLOW_ACCENT: + "absolute right-[-6rem] top-16 size-96 rounded-full bg-dungeon-gold/10 blur-3xl", + SHELL_GRID: + "absolute inset-0 bg-[url('/marketing/hex-grid.svg')] bg-[size:120px_104px] opacity-[0.08]", + SHELL_EDGE_FADE: + "absolute inset-x-0 top-0 h-24 bg-gradient-to-b from-background/82 to-transparent", + SECTION_GAP: "space-y-6 sm:space-y-8", + SPLIT_GRID: "grid gap-6 lg:grid-cols-[1.1fr_0.9fr]", + CARD_GRID: "grid gap-5 sm:grid-cols-2 xl:grid-cols-3", + CARD_SURFACE: "rounded-lg border border-border bg-card/80 shadow-none ring-0", + CARD_CONTENT: "p-5 sm:p-6", + SUBTLE_PANEL: "rounded-lg border border-border bg-card/70 shadow-none ring-0", + MAJOR_PANEL: "rounded-xl border border-border bg-card/80 shadow-none ring-0", + CTA_BAND: "rounded-xl border border-border bg-card/70 px-5 py-5 sm:px-6", + TOKEN_RAIL: + "flex w-full items-center gap-2 overflow-x-auto border-y border-border bg-card/40 px-4 py-3 backdrop-blur no-scrollbar", + LIGHT_ROW: "rounded-lg border border-border bg-card/70 transition-colors", + LIGHT_ROW_INTERACTIVE: + "rounded-lg border border-border bg-card/70 transition-colors hover:border-dungeon-gold/40 hover:bg-dungeon-gold/5", + TIMELINE_LIST: + "relative grid gap-4 rounded-xl border border-border bg-card/50 p-4 sm:p-5", + TIMELINE_ITEM: + "relative grid grid-cols-[auto_minmax(0,1fr)] gap-3 rounded-lg border border-border bg-card/80 px-4 py-4 sm:gap-4 sm:px-5", + TIMELINE_RAIL: "flex flex-col items-center pt-1", + TIMELINE_NODE: + "flex size-8 items-center justify-center rounded-full border border-dungeon-gold/45 bg-dungeon-gold/10 text-[0.7rem] font-bold text-dungeon-gold", + MAPPING_RAIL: + "grid gap-4 rounded-xl border border-border bg-card/50 p-4 sm:p-5", + MAPPING_ROW: + "grid gap-4 rounded-lg border border-border bg-card/80 p-4 sm:grid-cols-[minmax(0,0.75fr)_auto_minmax(0,1fr)] sm:items-center sm:p-5", +} as const; diff --git a/src/widgets/marketing-shell/config/marketingLogoConfig.ts b/src/widgets/marketing-shell/config/marketingLogoConfig.ts new file mode 100644 index 00000000..56437b8c --- /dev/null +++ b/src/widgets/marketing-shell/config/marketingLogoConfig.ts @@ -0,0 +1,20 @@ +export const RUNESTONE_LOGO_VARIANTS = { + DESKTOP: "desktop", + COMPACT: "compact", +} as const; + +export type RunestoneLogoVariant = + (typeof RUNESTONE_LOGO_VARIANTS)[keyof typeof RUNESTONE_LOGO_VARIANTS]; + +export const RUNESTONE_LOGO_SEGMENT_IDS = { + RUNE: "rune", + STONE: "stone", +} as const; + +export type RunestoneLogoSegmentId = + (typeof RUNESTONE_LOGO_SEGMENT_IDS)[keyof typeof RUNESTONE_LOGO_SEGMENT_IDS]; + +export const RUNESTONE_LOGO_SEGMENT_CLASS_NAMES = { + [RUNESTONE_LOGO_SEGMENT_IDS.RUNE]: "text-primary", + [RUNESTONE_LOGO_SEGMENT_IDS.STONE]: "text-accent", +} as const; diff --git a/src/widgets/marketing-shell/config/marketingShellConfig.ts b/src/widgets/marketing-shell/config/marketingShellConfig.ts new file mode 100644 index 00000000..d277b271 --- /dev/null +++ b/src/widgets/marketing-shell/config/marketingShellConfig.ts @@ -0,0 +1,61 @@ +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", + NAVIGATION_SHEET_TITLE: "Runestone", + NAVIGATION_SHEET_DESCRIPTION: "Navigate the playable architecture guide.", + FOOTER_COPYRIGHT: "© 2026 Runestone", + FOOTER_TAGLINE: "Playable architecture", + GITHUB_LABEL: "GitHub", + OPEN_NAVIGATION_LABEL: "Open navigation", +} 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..2f7b94e5 --- /dev/null +++ b/src/widgets/marketing-shell/index.ts @@ -0,0 +1,13 @@ +export { + MARKETING_LAYOUT_CLASS_NAMES, + MARKETING_NAVIGATION_ITEM_IDS, + MARKETING_ROUTES, + type MarketingNavigationItemId, +} from "./config"; +export { useMarketingLayoutRoute } from "./model"; +export { + MarketingNavigationSheet, + MarketingPageFrame, + 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/createRunestoneLogoViewModel.test.ts b/src/widgets/marketing-shell/lib/createRunestoneLogoViewModel.test.ts new file mode 100644 index 00000000..eb1301df --- /dev/null +++ b/src/widgets/marketing-shell/lib/createRunestoneLogoViewModel.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { RUNESTONE_LOGO_SEGMENT_IDS, RUNESTONE_LOGO_VARIANTS } from "../config"; + +import { createRunestoneLogoViewModel } from "./createRunestoneLogoViewModel"; + +describe("createRunestoneLogoViewModel", () => { + it("creates the desktop logo model", () => { + const viewModel = createRunestoneLogoViewModel({ + variant: RUNESTONE_LOGO_VARIANTS.DESKTOP, + }); + + expect(viewModel.ariaLabel).toBe("RUNESTONE"); + expect(viewModel.wordmarkClassName).toContain("tracking-[0.28em]"); + expect(viewModel.segments).toEqual([ + { + className: "text-primary", + id: RUNESTONE_LOGO_SEGMENT_IDS.RUNE, + label: "RUNE", + }, + { + className: "text-accent", + id: RUNESTONE_LOGO_SEGMENT_IDS.STONE, + label: "STONE", + }, + ]); + }); + + it("creates the compact logo model", () => { + const viewModel = createRunestoneLogoViewModel({ + variant: RUNESTONE_LOGO_VARIANTS.COMPACT, + }); + + expect(viewModel.wordmarkClassName).toContain("tracking-[0.2em]"); + expect(viewModel.segments).toEqual([ + { + className: "text-primary", + id: RUNESTONE_LOGO_SEGMENT_IDS.RUNE, + label: "R", + }, + { + className: "text-accent", + id: RUNESTONE_LOGO_SEGMENT_IDS.STONE, + label: "S", + }, + ]); + }); +}); diff --git a/src/widgets/marketing-shell/lib/createRunestoneLogoViewModel.ts b/src/widgets/marketing-shell/lib/createRunestoneLogoViewModel.ts new file mode 100644 index 00000000..c5a33a40 --- /dev/null +++ b/src/widgets/marketing-shell/lib/createRunestoneLogoViewModel.ts @@ -0,0 +1,58 @@ +import { cn } from "@/shared/lib"; + +import { + MARKETING_SHELL_COPY, + RUNESTONE_LOGO_SEGMENT_CLASS_NAMES, + RUNESTONE_LOGO_SEGMENT_IDS, + RUNESTONE_LOGO_VARIANTS, + type RunestoneLogoSegmentId, + type RunestoneLogoVariant, +} from "../config"; + +export type CreateRunestoneLogoViewModelInput = { + variant: RunestoneLogoVariant; +}; + +export type RunestoneLogoSegmentViewModel = { + className: string; + id: RunestoneLogoSegmentId; + label: string; +}; + +export type RunestoneLogoViewModel = { + ariaLabel: string; + segments: readonly RunestoneLogoSegmentViewModel[]; + wordmarkClassName: string; +}; + +export const createRunestoneLogoViewModel = ({ + variant, +}: CreateRunestoneLogoViewModelInput): RunestoneLogoViewModel => { + const isDesktop = variant === RUNESTONE_LOGO_VARIANTS.DESKTOP; + + return { + ariaLabel: MARKETING_SHELL_COPY.BRAND_NAME, + segments: [ + { + className: + RUNESTONE_LOGO_SEGMENT_CLASS_NAMES[RUNESTONE_LOGO_SEGMENT_IDS.RUNE], + id: RUNESTONE_LOGO_SEGMENT_IDS.RUNE, + label: isDesktop + ? MARKETING_SHELL_COPY.BRAND_RUNE_SEGMENT + : MARKETING_SHELL_COPY.COMPACT_BRAND_RUNE_SEGMENT, + }, + { + className: + RUNESTONE_LOGO_SEGMENT_CLASS_NAMES[RUNESTONE_LOGO_SEGMENT_IDS.STONE], + id: RUNESTONE_LOGO_SEGMENT_IDS.STONE, + label: isDesktop + ? MARKETING_SHELL_COPY.BRAND_STONE_SEGMENT + : MARKETING_SHELL_COPY.COMPACT_BRAND_STONE_SEGMENT, + }, + ], + wordmarkClassName: cn( + "text-base font-bold", + isDesktop ? "tracking-[0.28em] sm:text-lg" : "tracking-[0.2em]", + ), + }; +}; diff --git a/src/widgets/marketing-shell/lib/index.ts b/src/widgets/marketing-shell/lib/index.ts new file mode 100644 index 00000000..0bd561ec --- /dev/null +++ b/src/widgets/marketing-shell/lib/index.ts @@ -0,0 +1,14 @@ +export { + type CreateMarketingNavigationViewModelInput, + createMarketingNavigationViewModel, + type MarketingFooterLinkViewModel, + type MarketingNavigationLinkViewModel, + type MarketingNavigationViewModel, +} from "./createMarketingNavigationViewModel"; +export { + type CreateRunestoneLogoViewModelInput, + createRunestoneLogoViewModel, + type RunestoneLogoSegmentViewModel, + type RunestoneLogoViewModel, +} from "./createRunestoneLogoViewModel"; +export { resolveMarketingNavigationItemId } from "./resolveMarketingNavigationItemId"; diff --git a/src/widgets/marketing-shell/lib/resolveMarketingNavigationItemId.test.ts b/src/widgets/marketing-shell/lib/resolveMarketingNavigationItemId.test.ts new file mode 100644 index 00000000..e86444ba --- /dev/null +++ b/src/widgets/marketing-shell/lib/resolveMarketingNavigationItemId.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { MARKETING_NAVIGATION_ITEM_IDS, MARKETING_ROUTES } from "../config"; + +import { resolveMarketingNavigationItemId } from "./resolveMarketingNavigationItemId"; + +describe("resolveMarketingNavigationItemId", () => { + it("resolves the guide navigation item for the tutorial route", () => { + expect(resolveMarketingNavigationItemId(MARKETING_ROUTES.GUIDE)).toBe( + MARKETING_NAVIGATION_ITEM_IDS.GUIDE, + ); + }); + + it("resolves the concepts navigation item for the concepts route", () => { + expect(resolveMarketingNavigationItemId(MARKETING_ROUTES.CONCEPTS)).toBe( + MARKETING_NAVIGATION_ITEM_IDS.CONCEPTS, + ); + }); + + it("returns null for routes without a marketing navigation item", () => { + expect(resolveMarketingNavigationItemId(MARKETING_ROUTES.HOME)).toBeNull(); + expect(resolveMarketingNavigationItemId("/unknown")).toBeNull(); + }); +}); diff --git a/src/widgets/marketing-shell/lib/resolveMarketingNavigationItemId.ts b/src/widgets/marketing-shell/lib/resolveMarketingNavigationItemId.ts new file mode 100644 index 00000000..240a35e5 --- /dev/null +++ b/src/widgets/marketing-shell/lib/resolveMarketingNavigationItemId.ts @@ -0,0 +1,18 @@ +import { + MARKETING_NAVIGATION_ITEM_IDS, + MARKETING_ROUTES, + type MarketingNavigationItemId, +} from "../config"; + +export const resolveMarketingNavigationItemId = ( + pathname: string, +): MarketingNavigationItemId | null => { + switch (pathname) { + case MARKETING_ROUTES.GUIDE: + return MARKETING_NAVIGATION_ITEM_IDS.GUIDE; + case MARKETING_ROUTES.CONCEPTS: + return MARKETING_NAVIGATION_ITEM_IDS.CONCEPTS; + default: + return null; + } +}; diff --git a/src/widgets/marketing-shell/model/index.ts b/src/widgets/marketing-shell/model/index.ts new file mode 100644 index 00000000..d165d811 --- /dev/null +++ b/src/widgets/marketing-shell/model/index.ts @@ -0,0 +1,3 @@ +export { useMarketingLayoutRoute } from "./useMarketingLayoutRoute"; +export { useMarketingNavigation } from "./useMarketingNavigation"; +export { useRunestoneLogo } from "./useRunestoneLogo"; diff --git a/src/widgets/marketing-shell/model/useMarketingLayoutRoute.test.tsx b/src/widgets/marketing-shell/model/useMarketingLayoutRoute.test.tsx new file mode 100644 index 00000000..42c89aaa --- /dev/null +++ b/src/widgets/marketing-shell/model/useMarketingLayoutRoute.test.tsx @@ -0,0 +1,80 @@ +// @vitest-environment happy-dom + +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { AUTH_STATUS } from "@/features/auth"; + +const mockUseAuthContext = vi.hoisted(() => vi.fn()); +const mockNavigate = vi.hoisted(() => vi.fn()); +const mockUseLocation = 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", () => ({ + useLocation: mockUseLocation, + useRouter: () => ({ + navigate: mockNavigate, + }), +})); + +import { MARKETING_NAVIGATION_ITEM_IDS, MARKETING_ROUTES } from "../config"; + +import { useMarketingLayoutRoute } from "./useMarketingLayoutRoute"; + +const createAuthContext = () => ({ + errorMessage: null, + handleSessionBootstrapRetry: vi.fn(), + handleUsernameEntryDismiss: vi.fn(), + handleUsernameEntryRequest: vi.fn(), + handleUsernameFormSubmit: vi.fn(), + isAuthenticated: false, + isCheckingSession: false, + isUsernameModalOpen: true, + isUsernameSubmitting: false, + readyStatusLabel: null, + suggestedUsername: "Rune_AshBearAAAA", + authStatus: AUTH_STATUS.REQUIRES_USERNAME, +}); + +describe("useMarketingLayoutRoute", () => { + it("resolves the active navigation item and keeps the modal flow routed through the hook", () => { + const authContext = createAuthContext(); + + mockUseAuthContext.mockReturnValue(authContext); + mockUseLocation.mockReturnValue({ pathname: MARKETING_ROUTES.CONCEPTS }); + + const { result } = renderHook(() => useMarketingLayoutRoute()); + + expect(result.current.shellProps.activeNavigationItemId).toBe( + MARKETING_NAVIGATION_ITEM_IDS.CONCEPTS, + ); + expect(result.current.shellProps.isAuthenticated).toBe(false); + expect(result.current.shellProps.onEntryRequest).toBe( + authContext.handleUsernameEntryRequest, + ); + expect(result.current.usernameModalProps.isOpen).toBe(true); + expect(result.current.usernameModalProps.isSubmitting).toBe(false); + expect(result.current.usernameModalProps.suggestedUsername).toBe( + authContext.suggestedUsername, + ); + expect(result.current.usernameModalProps.onSubmit).toBe( + authContext.handleUsernameFormSubmit, + ); + + result.current.shellProps.onEntryRequest(); + result.current.usernameModalProps.onKeepReading(); + + expect(authContext.handleUsernameEntryRequest).toHaveBeenCalledOnce(); + expect(authContext.handleUsernameEntryDismiss).toHaveBeenCalledOnce(); + expect(mockNavigate).toHaveBeenCalledWith({ to: MARKETING_ROUTES.HOME }); + }); +}); diff --git a/src/widgets/marketing-shell/model/useMarketingLayoutRoute.ts b/src/widgets/marketing-shell/model/useMarketingLayoutRoute.ts new file mode 100644 index 00000000..0276f860 --- /dev/null +++ b/src/widgets/marketing-shell/model/useMarketingLayoutRoute.ts @@ -0,0 +1,57 @@ +import { useLocation, useRouter } from "@tanstack/react-router"; + +import { type UsernameFormInput, useAuthContext } from "@/features/auth"; + +import { MARKETING_ROUTES, type MarketingNavigationItemId } from "../config"; +import { resolveMarketingNavigationItemId } from "../lib"; + +type MarketingLayoutRouteViewModel = { + shellProps: { + activeNavigationItemId: MarketingNavigationItemId | null; + isAuthenticated: boolean; + onEntryRequest: () => void; + }; + usernameModalProps: { + errorMessage: string | null; + isOpen: boolean; + isSubmitting: boolean; + suggestedUsername: string; + onKeepReading: () => void; + onSubmit: (input: UsernameFormInput) => Promise; + }; +}; + +export const useMarketingLayoutRoute = (): MarketingLayoutRouteViewModel => { + const { + errorMessage, + handleUsernameEntryDismiss, + handleUsernameEntryRequest, + handleUsernameFormSubmit, + isAuthenticated, + isUsernameModalOpen, + isUsernameSubmitting, + suggestedUsername, + } = useAuthContext(); + const { pathname } = useLocation(); + const router = useRouter(); + const activeNavigationItemId = resolveMarketingNavigationItemId(pathname); + + return { + shellProps: { + activeNavigationItemId, + isAuthenticated, + onEntryRequest: handleUsernameEntryRequest, + }, + usernameModalProps: { + errorMessage, + isOpen: isUsernameModalOpen, + isSubmitting: isUsernameSubmitting, + suggestedUsername, + onKeepReading: () => { + handleUsernameEntryDismiss(); + void router.navigate({ to: MARKETING_ROUTES.HOME }); + }, + onSubmit: handleUsernameFormSubmit, + }, + }; +}; 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/model/useRunestoneLogo.test.tsx b/src/widgets/marketing-shell/model/useRunestoneLogo.test.tsx new file mode 100644 index 00000000..b708b189 --- /dev/null +++ b/src/widgets/marketing-shell/model/useRunestoneLogo.test.tsx @@ -0,0 +1,28 @@ +// @vitest-environment happy-dom + +import { renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { RUNESTONE_LOGO_VARIANTS } from "../config"; + +import { useRunestoneLogo } from "./useRunestoneLogo"; + +describe("useRunestoneLogo", () => { + it("returns the desktop logo view model", () => { + const { result } = renderHook(() => + useRunestoneLogo(RUNESTONE_LOGO_VARIANTS.DESKTOP), + ); + + expect(result.current.segments[0]?.label).toBe("RUNE"); + expect(result.current.segments[1]?.label).toBe("STONE"); + }); + + it("returns the compact logo view model", () => { + const { result } = renderHook(() => + useRunestoneLogo(RUNESTONE_LOGO_VARIANTS.COMPACT), + ); + + expect(result.current.segments[0]?.label).toBe("R"); + expect(result.current.segments[1]?.label).toBe("S"); + }); +}); diff --git a/src/widgets/marketing-shell/model/useRunestoneLogo.ts b/src/widgets/marketing-shell/model/useRunestoneLogo.ts new file mode 100644 index 00000000..37b064fc --- /dev/null +++ b/src/widgets/marketing-shell/model/useRunestoneLogo.ts @@ -0,0 +1,6 @@ +import type { RunestoneLogoVariant } from "../config"; +import { createRunestoneLogoViewModel } from "../lib"; + +export const useRunestoneLogo = (variant: RunestoneLogoVariant) => { + return createRunestoneLogoViewModel({ variant }); +}; diff --git a/src/widgets/marketing-shell/ui/MarketingFooter.tsx b/src/widgets/marketing-shell/ui/MarketingFooter.tsx new file mode 100644 index 00000000..f6942e23 --- /dev/null +++ b/src/widgets/marketing-shell/ui/MarketingFooter.tsx @@ -0,0 +1,72 @@ +import { Link } from "@tanstack/react-router"; + +import { cn } from "@/shared/lib"; + +import { + MARKETING_LAYOUT_CLASS_NAMES, + MARKETING_ROUTES, + MARKETING_SHELL_COPY, +} from "../config"; +import type { MarketingNavigationViewModel } from "../lib"; + +type MarketingFooterProps = { + viewModel: MarketingNavigationViewModel; +}; + +export function MarketingFooter({ viewModel }: MarketingFooterProps) { + return ( +
    +
    + + + {MARKETING_SHELL_COPY.BRAND_RUNE_SEGMENT} + + + {MARKETING_SHELL_COPY.BRAND_STONE_SEGMENT} + + + + + +

    + {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..88091fec --- /dev/null +++ b/src/widgets/marketing-shell/ui/MarketingHeader.tsx @@ -0,0 +1,114 @@ +import { Link } from "@tanstack/react-router"; +import { DoorOpen } from "lucide-react"; + +import { cn } from "@/shared/lib"; +import { Button } from "@/shared/ui"; + +import { + MARKETING_LAYOUT_CLASS_NAMES, + MARKETING_ROUTES, + MARKETING_SHELL_COPY, + RUNESTONE_LOGO_VARIANTS, +} from "../config"; +import type { MarketingNavigationViewModel } from "../lib"; +import { MarketingNavigationSheet } from "./MarketingNavigationSheet"; +import { RunestoneLogo } from "./RunestoneLogo"; + +type MarketingHeaderProps = { + isAuthenticated: boolean; + onEntryRequest: () => void; + viewModel: MarketingNavigationViewModel; +}; + +export function MarketingHeader({ + isAuthenticated, + onEntryRequest, + viewModel, +}: MarketingHeaderProps) { + return ( +
    +
    +
    + +
    +
    + +
    + + + +
    + {isAuthenticated ? ( + + ) : ( + + )} + + {isAuthenticated ? ( + + ) : ( + + )} + + +
    +
    +
    + ); +} diff --git a/src/widgets/marketing-shell/ui/MarketingNavigationSheet.test.tsx b/src/widgets/marketing-shell/ui/MarketingNavigationSheet.test.tsx new file mode 100644 index 00000000..bbe81897 --- /dev/null +++ b/src/widgets/marketing-shell/ui/MarketingNavigationSheet.test.tsx @@ -0,0 +1,109 @@ +// @vitest-environment happy-dom + +import { + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + MARKETING_FOOTER_LINKS, + MARKETING_NAVIGATION_ITEM_IDS, + MARKETING_NAVIGATION_ITEMS, + MARKETING_SHELL_COPY, +} from "../config"; +import { createMarketingNavigationViewModel } from "../lib"; +import { MarketingNavigationSheet } from "./MarketingNavigationSheet"; + +vi.mock("@tanstack/react-router", () => ({ + Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => ( + + {children} + + ), +})); + +afterEach(() => { + cleanup(); +}); + +const renderSheet = (isAuthenticated: boolean, onEntryRequest = vi.fn()) => { + const viewModel = createMarketingNavigationViewModel({ + activeNavigationItemId: MARKETING_NAVIGATION_ITEM_IDS.GUIDE, + footerLinks: MARKETING_FOOTER_LINKS, + navigationItems: MARKETING_NAVIGATION_ITEMS, + }); + + render( + , + ); +}; + +describe("MarketingNavigationSheet", () => { + it("opens a left-side sheet with the shared navigation links", () => { + renderSheet(true); + + expect(screen.queryByRole("dialog")).toBeNull(); + + fireEvent.click( + screen.getByRole("button", { + name: MARKETING_SHELL_COPY.OPEN_NAVIGATION_LABEL, + }), + ); + + const dialog = screen.getByRole("dialog", { + name: MARKETING_SHELL_COPY.NAVIGATION_SHEET_TITLE, + }); + + expect(dialog.getAttribute("data-side")).toBe("left"); + + const dialogQueries = within(dialog); + + expect( + dialogQueries.getByText( + MARKETING_SHELL_COPY.NAVIGATION_SHEET_DESCRIPTION, + ), + ).toBeTruthy(); + expect(dialogQueries.getByRole("link", { name: "Guide" })).toBeTruthy(); + expect(dialogQueries.getByRole("link", { name: "Concepts" })).toBeTruthy(); + expect(dialogQueries.getByRole("link", { name: "GitHub" })).toBeTruthy(); + expect(dialogQueries.queryByText("Repo")).toBeNull(); + expect( + dialogQueries.getByRole("link", { + name: MARKETING_SHELL_COPY.ENTER_DUNGEON_LABEL, + }), + ).toBeTruthy(); + }); + + it("requests username entry when unauthenticated", () => { + const handleEntryRequest = vi.fn(); + + renderSheet(false, handleEntryRequest); + + fireEvent.click( + screen.getByRole("button", { + name: MARKETING_SHELL_COPY.OPEN_NAVIGATION_LABEL, + }), + ); + + const dialog = screen.getByRole("dialog", { + name: MARKETING_SHELL_COPY.NAVIGATION_SHEET_TITLE, + }); + const dialogQueries = within(dialog); + const entryButton = dialogQueries.getByRole("button", { + name: MARKETING_SHELL_COPY.ENTER_DUNGEON_LABEL, + }); + + fireEvent.click(entryButton); + + expect(handleEntryRequest).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/widgets/marketing-shell/ui/MarketingNavigationSheet.tsx b/src/widgets/marketing-shell/ui/MarketingNavigationSheet.tsx new file mode 100644 index 00000000..9d72fdd4 --- /dev/null +++ b/src/widgets/marketing-shell/ui/MarketingNavigationSheet.tsx @@ -0,0 +1,137 @@ +import { Link } from "@tanstack/react-router"; +import { DoorOpen, Github, Menu } from "lucide-react"; + +import { cn } from "@/shared/lib"; +import { + Button, + Separator, + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/shared/ui"; + +import { + MARKETING_ROUTES, + MARKETING_SHELL_COPY, + RUNESTONE_LOGO_VARIANTS, +} from "../config"; +import type { MarketingNavigationViewModel } from "../lib"; +import { RunestoneLogo } from "./RunestoneLogo"; + +type MarketingNavigationSheetProps = { + isAuthenticated: boolean; + onEntryRequest: () => void; + viewModel: MarketingNavigationViewModel; +}; + +export function MarketingNavigationSheet({ + isAuthenticated, + onEntryRequest, + viewModel, +}: MarketingNavigationSheetProps) { + return ( + + + + + + +
    +
    + +
    + + + + {MARKETING_SHELL_COPY.NAVIGATION_SHEET_TITLE} + + + {MARKETING_SHELL_COPY.NAVIGATION_SHEET_DESCRIPTION} + + + + + +
    + + + + {isAuthenticated ? ( + + + + ) : ( + + + + )} + +
    +
    +
    +
    + ); +} diff --git a/src/widgets/marketing-shell/ui/MarketingPageFrame.tsx b/src/widgets/marketing-shell/ui/MarketingPageFrame.tsx new file mode 100644 index 00000000..89015d25 --- /dev/null +++ b/src/widgets/marketing-shell/ui/MarketingPageFrame.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from "react"; + +import { cn } from "@/shared/lib"; + +import { MARKETING_LAYOUT_CLASS_NAMES } from "../config"; + +type MarketingPageFrameProps = { + children: ReactNode; + className?: string; +}; + +export function MarketingPageFrame({ + children, + className, +}: MarketingPageFrameProps) { + return ( +
    + {children} +
    + ); +} diff --git a/src/widgets/marketing-shell/ui/MarketingShell.test.tsx b/src/widgets/marketing-shell/ui/MarketingShell.test.tsx new file mode 100644 index 00000000..2b451b2a --- /dev/null +++ b/src/widgets/marketing-shell/ui/MarketingShell.test.tsx @@ -0,0 +1,59 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { MARKETING_NAVIGATION_ITEM_IDS, MARKETING_SHELL_COPY } from "../config"; +import { MarketingShell } from "./MarketingShell"; + +vi.mock("@tanstack/react-router", () => ({ + Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => ( + + {children} + + ), +})); + +afterEach(cleanup); + +describe("MarketingShell", () => { + it("renders the shared navigation shell once for marketing routes", () => { + render( + +
    Shell content
    +
    , + ); + + expect( + screen.getAllByRole("link", { + name: MARKETING_SHELL_COPY.BRAND_NAME, + }).length, + ).toBeGreaterThan(0); + expect( + screen.getAllByRole("link", { + name: "Guide", + }).length, + ).toBeGreaterThan(0); + expect( + screen.getAllByRole("link", { + name: "Concepts", + }).length, + ).toBeGreaterThan(0); + expect( + screen.getByRole("link", { + name: "GitHub", + }), + ).not.toBeNull(); + expect( + screen.getByRole("button", { + name: MARKETING_SHELL_COPY.OPEN_NAVIGATION_LABEL, + }), + ).not.toBeNull(); + expect(screen.getByText("Shell content")).not.toBeNull(); + }); +}); diff --git a/src/widgets/marketing-shell/ui/MarketingShell.tsx b/src/widgets/marketing-shell/ui/MarketingShell.tsx new file mode 100644 index 00000000..8dd7bcc1 --- /dev/null +++ b/src/widgets/marketing-shell/ui/MarketingShell.tsx @@ -0,0 +1,52 @@ +import type { ReactNode } from "react"; + +import type { MarketingNavigationItemId } from "../config"; +import { MARKETING_LAYOUT_CLASS_NAMES } from "../config"; +import { useMarketingNavigation } from "../model"; +import { MarketingFooter } from "./MarketingFooter"; +import { MarketingHeader } from "./MarketingHeader"; + +type MarketingShellProps = { + activeNavigationItemId: MarketingNavigationItemId | null; + children: ReactNode; + isAuthenticated: boolean; + onEntryRequest: () => void; +}; + +export function MarketingShell({ + activeNavigationItemId, + children, + isAuthenticated, + onEntryRequest, +}: MarketingShellProps) { + const viewModel = useMarketingNavigation(activeNavigationItemId); + + return ( +
    +