+
+
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}
+
+
+
+
+
+
+
+
+
+
+ {nodes.map((node) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ );
+}
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_COPY.FEATURES_HEADING}
-
-
-
- {HOME_FEATURES.map(({ detail, title }) => (
- -
-
-
-
- {title}
-
-
- {detail}
-
-
-
- ))}
-
-
-
-
-
-
-
+
>
);
}
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 (
+
+
+
+
+ {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}
+
+
+
+ {item.target}
+
+
+ ))}
+
+ );
+}
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 (
+
+
+
+
+ {steps.map((step, index) => (
+ -
+
+
+ {String(index + 1).padStart(2, "0")}
+
+ {index < steps.length - 1 ? (
+
+ ) : null}
+
+
+
+
+
+
+ {step.label}
+
+
+ {step.tokens.map((token) => (
+
+ {token.label}
+
+ ))}
+
+
+ {step.detail}
+
+
+
+
+ ))}
+
+
+ );
+}
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 (
+
+ );
+}
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 }) => (
- -
-
- {step}
-
-
-
- ))}
-
-
- );
-}
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) => (
- -
-
- {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 (
+
+ );
+}
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 (
+
+ );
+}
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 (
+
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
diff --git a/src/widgets/marketing-shell/ui/RunestoneLogo.tsx b/src/widgets/marketing-shell/ui/RunestoneLogo.tsx
new file mode 100644
index 00000000..80d94563
--- /dev/null
+++ b/src/widgets/marketing-shell/ui/RunestoneLogo.tsx
@@ -0,0 +1,41 @@
+import { Link } from "@tanstack/react-router";
+import { cn } from "@/shared/lib";
+
+import runestoneMarkUrl from "../assets/runestone-mark.png";
+import { MARKETING_ROUTES, type RunestoneLogoVariant } from "../config";
+import { useRunestoneLogo } from "../model";
+
+type RunestoneLogoProps = {
+ variant: RunestoneLogoVariant;
+};
+
+export function RunestoneLogo({ variant }: RunestoneLogoProps) {
+ const { ariaLabel, segments, wordmarkClassName } = useRunestoneLogo(variant);
+
+ return (
+
+
+
+
+
+ {segments.map((segment) => (
+
+ {segment.label}
+
+ ))}
+
+
+ );
+}
diff --git a/src/widgets/marketing-shell/ui/index.ts b/src/widgets/marketing-shell/ui/index.ts
new file mode 100644
index 00000000..22ba4d4c
--- /dev/null
+++ b/src/widgets/marketing-shell/ui/index.ts
@@ -0,0 +1,4 @@
+export { MarketingNavigationSheet } from "./MarketingNavigationSheet";
+export { MarketingPageFrame } from "./MarketingPageFrame";
+export { MarketingShell } from "./MarketingShell";
+export { RunestoneLogo } from "./RunestoneLogo";