From 6e43d0d170f4c8ac77b4cfd577059e3fa9c73e51 Mon Sep 17 00:00:00 2001 From: Tshepang Date: Mon, 25 May 2026 18:44:56 +0200 Subject: [PATCH 01/11] =?UTF-8?q?test(marketing-shell):=20add=20navigation?= =?UTF-8?q?=20view=20model=20tests=20=E2=80=94=20RED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...createMarketingNavigationViewModel.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/widgets/marketing-shell/lib/createMarketingNavigationViewModel.test.ts 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 0000000..95668e3 --- /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, + ); + }); +}); From 4b0297969bdb971ff1c52ce940a495659cf82c5a Mon Sep 17 00:00:00 2001 From: Tshepang Date: Mon, 25 May 2026 18:45:11 +0200 Subject: [PATCH 02/11] feat(marketing-shell): add navigation config and model feat(marketing-shell): add shared shell ui --- src/widgets/marketing-shell/config/index.ts | 8 ++ .../config/marketingShellConfig.ts | 59 ++++++++++ src/widgets/marketing-shell/index.ts | 6 ++ .../lib/createMarketingNavigationViewModel.ts | 47 ++++++++ src/widgets/marketing-shell/lib/index.ts | 7 ++ src/widgets/marketing-shell/model/index.ts | 1 + .../model/useMarketingNavigation.ts | 16 +++ .../marketing-shell/ui/MarketingFooter.tsx | 46 ++++++++ .../marketing-shell/ui/MarketingHeader.tsx | 94 ++++++++++++++++ .../ui/MarketingNavigationDrawer.tsx | 102 ++++++++++++++++++ .../marketing-shell/ui/MarketingShell.tsx | 41 +++++++ .../marketing-shell/ui/RunestoneLogo.tsx | 59 ++++++++++ src/widgets/marketing-shell/ui/index.ts | 2 + 13 files changed, 488 insertions(+) create mode 100644 src/widgets/marketing-shell/config/index.ts create mode 100644 src/widgets/marketing-shell/config/marketingShellConfig.ts create mode 100644 src/widgets/marketing-shell/index.ts create mode 100644 src/widgets/marketing-shell/lib/createMarketingNavigationViewModel.ts create mode 100644 src/widgets/marketing-shell/lib/index.ts create mode 100644 src/widgets/marketing-shell/model/index.ts create mode 100644 src/widgets/marketing-shell/model/useMarketingNavigation.ts create mode 100644 src/widgets/marketing-shell/ui/MarketingFooter.tsx create mode 100644 src/widgets/marketing-shell/ui/MarketingHeader.tsx create mode 100644 src/widgets/marketing-shell/ui/MarketingNavigationDrawer.tsx create mode 100644 src/widgets/marketing-shell/ui/MarketingShell.tsx create mode 100644 src/widgets/marketing-shell/ui/RunestoneLogo.tsx create mode 100644 src/widgets/marketing-shell/ui/index.ts diff --git a/src/widgets/marketing-shell/config/index.ts b/src/widgets/marketing-shell/config/index.ts new file mode 100644 index 0000000..c248557 --- /dev/null +++ b/src/widgets/marketing-shell/config/index.ts @@ -0,0 +1,8 @@ +export { + MARKETING_FOOTER_LINKS, + MARKETING_NAVIGATION_ITEM_IDS, + MARKETING_NAVIGATION_ITEMS, + MARKETING_ROUTES, + MARKETING_SHELL_COPY, + type MarketingNavigationItemId, +} from "./marketingShellConfig"; diff --git a/src/widgets/marketing-shell/config/marketingShellConfig.ts b/src/widgets/marketing-shell/config/marketingShellConfig.ts new file mode 100644 index 0000000..b9c09cf --- /dev/null +++ b/src/widgets/marketing-shell/config/marketingShellConfig.ts @@ -0,0 +1,59 @@ +export const MARKETING_NAVIGATION_ITEM_IDS = { + GUIDE: "guide", + CONCEPTS: "concepts", +} as const; + +export type MarketingNavigationItemId = + (typeof MARKETING_NAVIGATION_ITEM_IDS)[keyof typeof MARKETING_NAVIGATION_ITEM_IDS]; + +export const MARKETING_ROUTES = { + HOME: "/", + GAME: "/game", + GUIDE: "/tutorial", + CONCEPTS: "/concepts", + GITHUB_REPOSITORY: "https://github.com/Teeldinho/runestone", +} as const; + +export const MARKETING_SHELL_COPY = { + BRAND_NAME: "RUNESTONE", + BRAND_RUNE_SEGMENT: "RUNE", + BRAND_STONE_SEGMENT: "STONE", + COMPACT_BRAND_RUNE_SEGMENT: "R", + COMPACT_BRAND_STONE_SEGMENT: "S", + ENTER_DUNGEON_LABEL: "Enter Dungeon", + DRAWER_TITLE: "Runestone", + DRAWER_DESCRIPTION: "Navigate the playable architecture guide.", + FOOTER_COPYRIGHT: "© 2026 Runestone", + FOOTER_TAGLINE: "Playable architecture", +} as const; + +export const MARKETING_NAVIGATION_ITEMS = [ + { + id: MARKETING_NAVIGATION_ITEM_IDS.GUIDE, + label: "Guide", + to: MARKETING_ROUTES.GUIDE, + }, + { + id: MARKETING_NAVIGATION_ITEM_IDS.CONCEPTS, + label: "Concepts", + to: MARKETING_ROUTES.CONCEPTS, + }, +] as const; + +export const MARKETING_FOOTER_LINKS = [ + { + label: "Guide", + to: MARKETING_ROUTES.GUIDE, + type: "internal", + }, + { + label: "Concepts", + to: MARKETING_ROUTES.CONCEPTS, + type: "internal", + }, + { + label: "GitHub", + to: MARKETING_ROUTES.GITHUB_REPOSITORY, + type: "external", + }, +] as const; diff --git a/src/widgets/marketing-shell/index.ts b/src/widgets/marketing-shell/index.ts new file mode 100644 index 0000000..614d3ee --- /dev/null +++ b/src/widgets/marketing-shell/index.ts @@ -0,0 +1,6 @@ +export { + MARKETING_NAVIGATION_ITEM_IDS, + MARKETING_ROUTES, + type MarketingNavigationItemId, +} from "./config"; +export { MarketingShell, RunestoneLogo } from "./ui"; diff --git a/src/widgets/marketing-shell/lib/createMarketingNavigationViewModel.ts b/src/widgets/marketing-shell/lib/createMarketingNavigationViewModel.ts new file mode 100644 index 0000000..fe594c2 --- /dev/null +++ b/src/widgets/marketing-shell/lib/createMarketingNavigationViewModel.ts @@ -0,0 +1,47 @@ +import type { + MARKETING_FOOTER_LINKS, + MARKETING_NAVIGATION_ITEMS, + MarketingNavigationItemId, +} from "../config"; + +type MarketingNavigationItem = (typeof MARKETING_NAVIGATION_ITEMS)[number]; + +type MarketingFooterLink = (typeof MARKETING_FOOTER_LINKS)[number]; + +type CreateMarketingNavigationViewModelInput = { + activeNavigationItemId: MarketingNavigationItemId | null; + footerLinks: readonly MarketingFooterLink[]; + navigationItems: readonly MarketingNavigationItem[]; +}; + +type MarketingNavigationLinkViewModel = MarketingNavigationItem & { + isActive: boolean; +}; + +type MarketingFooterLinkViewModel = MarketingFooterLink; + +type MarketingNavigationViewModel = { + footerLinks: readonly MarketingFooterLinkViewModel[]; + navigationItems: readonly MarketingNavigationLinkViewModel[]; +}; + +export const createMarketingNavigationViewModel = ({ + activeNavigationItemId, + footerLinks, + navigationItems, +}: CreateMarketingNavigationViewModelInput): MarketingNavigationViewModel => { + return { + footerLinks, + navigationItems: navigationItems.map((item) => ({ + ...item, + isActive: item.id === activeNavigationItemId, + })), + }; +}; + +export type { + CreateMarketingNavigationViewModelInput, + MarketingFooterLinkViewModel, + MarketingNavigationLinkViewModel, + MarketingNavigationViewModel, +}; diff --git a/src/widgets/marketing-shell/lib/index.ts b/src/widgets/marketing-shell/lib/index.ts new file mode 100644 index 0000000..f9cb151 --- /dev/null +++ b/src/widgets/marketing-shell/lib/index.ts @@ -0,0 +1,7 @@ +export { + type CreateMarketingNavigationViewModelInput, + createMarketingNavigationViewModel, + type MarketingFooterLinkViewModel, + type MarketingNavigationLinkViewModel, + type MarketingNavigationViewModel, +} from "./createMarketingNavigationViewModel"; diff --git a/src/widgets/marketing-shell/model/index.ts b/src/widgets/marketing-shell/model/index.ts new file mode 100644 index 0000000..dee1dfa --- /dev/null +++ b/src/widgets/marketing-shell/model/index.ts @@ -0,0 +1 @@ +export { useMarketingNavigation } from "./useMarketingNavigation"; diff --git a/src/widgets/marketing-shell/model/useMarketingNavigation.ts b/src/widgets/marketing-shell/model/useMarketingNavigation.ts new file mode 100644 index 0000000..95af245 --- /dev/null +++ b/src/widgets/marketing-shell/model/useMarketingNavigation.ts @@ -0,0 +1,16 @@ +import { + MARKETING_FOOTER_LINKS, + MARKETING_NAVIGATION_ITEMS, + type MarketingNavigationItemId, +} from "../config"; +import { createMarketingNavigationViewModel } from "../lib"; + +export const useMarketingNavigation = ( + activeNavigationItemId: MarketingNavigationItemId | null, +) => { + return createMarketingNavigationViewModel({ + activeNavigationItemId, + footerLinks: MARKETING_FOOTER_LINKS, + navigationItems: MARKETING_NAVIGATION_ITEMS, + }); +}; diff --git a/src/widgets/marketing-shell/ui/MarketingFooter.tsx b/src/widgets/marketing-shell/ui/MarketingFooter.tsx new file mode 100644 index 0000000..1769942 --- /dev/null +++ b/src/widgets/marketing-shell/ui/MarketingFooter.tsx @@ -0,0 +1,46 @@ +import { Link } from "@tanstack/react-router"; + +import { MARKETING_SHELL_COPY } from "../config"; +import type { MarketingNavigationViewModel } from "../lib"; + +type MarketingFooterProps = { + viewModel: MarketingNavigationViewModel; +}; + +export function MarketingFooter({ viewModel }: MarketingFooterProps) { + return ( +
+
+

{MARKETING_SHELL_COPY.FOOTER_COPYRIGHT}

+ + + +

+ {MARKETING_SHELL_COPY.FOOTER_TAGLINE} +

+
+
+ ); +} diff --git a/src/widgets/marketing-shell/ui/MarketingHeader.tsx b/src/widgets/marketing-shell/ui/MarketingHeader.tsx new file mode 100644 index 0000000..f870a4a --- /dev/null +++ b/src/widgets/marketing-shell/ui/MarketingHeader.tsx @@ -0,0 +1,94 @@ +import { Link } from "@tanstack/react-router"; +import { DoorOpen } from "lucide-react"; + +import { cn } from "@/shared/lib"; +import { Button } from "@/shared/ui"; + +import { MARKETING_ROUTES, MARKETING_SHELL_COPY } from "../config"; +import type { MarketingNavigationViewModel } from "../lib"; +import { MarketingNavigationDrawer } from "./MarketingNavigationDrawer"; +import { RunestoneLogo } from "./RunestoneLogo"; + +type MarketingHeaderProps = { + isAuthenticated: boolean; + viewModel: MarketingNavigationViewModel; +}; + +export function MarketingHeader({ + isAuthenticated, + viewModel, +}: MarketingHeaderProps) { + return ( +
+
+
+ +
+
+ +
+ + + +
+ {isAuthenticated ? ( + + ) : ( + + )} + + {isAuthenticated ? ( + + ) : ( + + )} + + +
+
+
+ ); +} diff --git a/src/widgets/marketing-shell/ui/MarketingNavigationDrawer.tsx b/src/widgets/marketing-shell/ui/MarketingNavigationDrawer.tsx new file mode 100644 index 0000000..9b8552b --- /dev/null +++ b/src/widgets/marketing-shell/ui/MarketingNavigationDrawer.tsx @@ -0,0 +1,102 @@ +import { Link } from "@tanstack/react-router"; +import { DoorOpen, Github, Menu } from "lucide-react"; + +import { cn } from "@/shared/lib"; +import { + Button, + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, + Separator, +} from "@/shared/ui"; + +import { MARKETING_ROUTES, MARKETING_SHELL_COPY } from "../config"; +import type { MarketingNavigationViewModel } from "../lib"; + +type MarketingNavigationDrawerProps = { + isAuthenticated: boolean; + viewModel: MarketingNavigationViewModel; +}; + +export function MarketingNavigationDrawer({ + isAuthenticated, + viewModel, +}: MarketingNavigationDrawerProps) { + return ( + + + + + + + {MARKETING_SHELL_COPY.DRAWER_TITLE} + + {MARKETING_SHELL_COPY.DRAWER_DESCRIPTION} + + + + + + + + + {isAuthenticated ? ( + + + + ) : ( + + )} + + + + ); +} diff --git a/src/widgets/marketing-shell/ui/MarketingShell.tsx b/src/widgets/marketing-shell/ui/MarketingShell.tsx new file mode 100644 index 0000000..3025412 --- /dev/null +++ b/src/widgets/marketing-shell/ui/MarketingShell.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from "react"; + +import type { MarketingNavigationItemId } from "../config"; +import { useMarketingNavigation } from "../model"; +import { MarketingFooter } from "./MarketingFooter"; +import { MarketingHeader } from "./MarketingHeader"; + +type MarketingShellProps = { + activeNavigationItemId: MarketingNavigationItemId | null; + children: ReactNode; + isAuthenticated: boolean; +}; + +export function MarketingShell({ + activeNavigationItemId, + children, + isAuthenticated, +}: MarketingShellProps) { + const viewModel = useMarketingNavigation(activeNavigationItemId); + + return ( +
+
    - {TUTORIAL_CAMERA_MODES.map((mode) => ( + {cameraModes.map((mode) => (
  • - {group.tone === "accent" ? ( + {group.tone === TUTORIAL_CONTROL_TONES.ACCENT ? ( ) : ( @@ -47,10 +43,7 @@ export function TutorialControlGroupCard({
      {group.rows.map((row) => ( - + ))}
    diff --git a/src/pages/tutorial/ui/TutorialControlRow.tsx b/src/pages/tutorial/ui/TutorialControlRow.tsx index 28d0068..7e8455b 100644 --- a/src/pages/tutorial/ui/TutorialControlRow.tsx +++ b/src/pages/tutorial/ui/TutorialControlRow.tsx @@ -1,16 +1,10 @@ -import { ChevronsUp, Footprints, Gamepad2 } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; import { cn } from "@/shared/lib"; -const TUTORIAL_MOBILE_SHORTCUT_ICONS = { - ChevronsUp, - Footprints, - Gamepad2, -} as const; - type TutorialControlRowData = { label: string; - mobileIcon?: keyof typeof TUTORIAL_MOBILE_SHORTCUT_ICONS; + mobileIcon?: LucideIcon | null; mobileLabel?: string; shortcuts: readonly string[]; }; @@ -20,11 +14,8 @@ type TutorialControlRowProps = { }; export function TutorialControlRow({ row }: TutorialControlRowProps) { - const mobileIcon = row.mobileIcon; - const hasMobileIcon = mobileIcon != null; - const MobileShortcutIcon = mobileIcon - ? TUTORIAL_MOBILE_SHORTCUT_ICONS[mobileIcon] - : null; + const MobileShortcutIcon = row.mobileIcon; + const hasMobileIcon = MobileShortcutIcon != null; return (
  • diff --git a/src/pages/tutorial/ui/TutorialControlsPanel.tsx b/src/pages/tutorial/ui/TutorialControlsPanel.tsx index 51ffe44..33e5359 100644 --- a/src/pages/tutorial/ui/TutorialControlsPanel.tsx +++ b/src/pages/tutorial/ui/TutorialControlsPanel.tsx @@ -1,9 +1,17 @@ -import { TUTORIAL_CONTROL_GROUPS, TUTORIAL_CONTROLS_COPY } from "../config"; +import type { TutorialPageViewModel } from "../lib"; import { TutorialCameraModesCard } from "./TutorialCameraModesCard"; import { TutorialControlGroupCard } from "./TutorialControlGroupCard"; -export function TutorialControlsPanel() { +type TutorialControlsPanelProps = TutorialPageViewModel["controlsSectionProps"]; + +export function TutorialControlsPanel({ + cameraHeading, + cameraModes, + controlGroups, + description, + heading, +}: TutorialControlsPanelProps) { return (
    - {TUTORIAL_CONTROLS_COPY.SECTION_HEADING} + {heading}

    - {TUTORIAL_CONTROLS_COPY.SECTION_DESCRIPTION} + {description}

- {TUTORIAL_CONTROL_GROUPS.map((group) => ( + {controlGroups.map((group) => ( ))}
- +
); diff --git a/src/pages/tutorial/ui/TutorialFirstRunPanel.tsx b/src/pages/tutorial/ui/TutorialFirstRunPanel.tsx index 9dc9769..cb43918 100644 --- a/src/pages/tutorial/ui/TutorialFirstRunPanel.tsx +++ b/src/pages/tutorial/ui/TutorialFirstRunPanel.tsx @@ -2,12 +2,17 @@ import { cn } from "@/shared/lib"; import { Badge } from "@/shared/ui"; import { - TUTORIAL_FIRST_RUN_COPY, - TUTORIAL_FIRST_RUN_STEPS, TUTORIAL_FIRST_RUN_TONE_CLASS_NAMES, + TUTORIAL_FIRST_RUN_TONES, } from "../config"; +import type { TutorialPageViewModel } from "../lib"; -export function TutorialFirstRunPanel() { +type TutorialFirstRunPanelProps = TutorialPageViewModel["firstRunSectionProps"]; + +export function TutorialFirstRunPanel({ + heading, + steps, +}: TutorialFirstRunPanelProps) { return (
@@ -15,13 +20,13 @@ export function TutorialFirstRunPanel() { id="first-run-heading" className="text-2xl font-semibold text-foreground sm:text-3xl" > - {TUTORIAL_FIRST_RUN_COPY.SECTION_HEADING} + {heading}
    - {TUTORIAL_FIRST_RUN_STEPS.map((step, index) => ( + {steps.map((step, index) => (
  1. {String(index + 1).padStart(2, "0")}
- {index < TUTORIAL_FIRST_RUN_STEPS.length - 1 ? ( + {index < steps.length - 1 ? (
) : null}
@@ -56,13 +61,11 @@ export function TutorialFirstRunPanel() { className={cn( "rounded-sm border bg-background/40 px-2 py-1 text-[0.65rem] font-semibold uppercase tracking-[0.12em]", TUTORIAL_FIRST_RUN_TONE_CLASS_NAMES[token.tone], - token.tone === "accent" - ? "border-dungeon-gold/30" - : token.tone === "sealed" - ? "border-dungeon-rune-sealed/30" - : token.tone === "success" - ? "border-success/30" - : "border-dungeon-gold/30", + token.tone === TUTORIAL_FIRST_RUN_TONES.SEALED + ? "border-dungeon-rune-sealed/30" + : token.tone === TUTORIAL_FIRST_RUN_TONES.SUCCESS + ? "border-success/30" + : "border-dungeon-gold/30", )} > {token.label} diff --git a/src/pages/tutorial/ui/TutorialPage.tsx b/src/pages/tutorial/ui/TutorialPage.tsx index 45b2d8a..af8df72 100644 --- a/src/pages/tutorial/ui/TutorialPage.tsx +++ b/src/pages/tutorial/ui/TutorialPage.tsx @@ -1,21 +1,19 @@ -import { useAuthContext } from "@/features/auth"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui"; import { TUTORIAL_TAB_IDS, TUTORIAL_TABS } from "../config"; +import { useTutorialPage } from "../model"; import { TutorialControlsPanel } from "./TutorialControlsPanel"; import { TutorialFirstRunPanel } from "./TutorialFirstRunPanel"; import { TutorialHero } from "./TutorialHero"; export function TutorialPage() { - const { handleUsernameEntryRequest, isAuthenticated } = useAuthContext(); + const { controlsSectionProps, firstRunSectionProps, heroProps } = + useTutorialPage(); return ( <> - + @@ -31,11 +29,11 @@ export function TutorialPage() { - + - + diff --git a/src/widgets/marketing-shell/config/marketingLayoutConfig.ts b/src/widgets/marketing-shell/config/marketingLayoutConfig.ts index 08eb154..2a9d905 100644 --- a/src/widgets/marketing-shell/config/marketingLayoutConfig.ts +++ b/src/widgets/marketing-shell/config/marketingLayoutConfig.ts @@ -1,6 +1,6 @@ export const MARKETING_LAYOUT_CLASS_NAMES = { PAGE_FRAME: - "mx-auto flex w-full max-w-7xl flex-col gap-9 px-4 py-8 sm:gap-11 sm:px-6 sm:py-10 lg:gap-12 lg:px-8 lg:py-12", + "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", @@ -12,9 +12,9 @@ export const MARKETING_LAYOUT_CLASS_NAMES = { "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-5", - SPLIT_GRID: "grid gap-5 lg:grid-cols-[1.1fr_0.9fr]", - CARD_GRID: "grid gap-4 sm:grid-cols-2 xl:grid-cols-3", + 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", @@ -26,14 +26,14 @@ export const MARKETING_LAYOUT_CLASS_NAMES = { 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-3 rounded-xl border border-border bg-card/50 p-4 sm:p-5", + "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-3 rounded-xl border border-border bg-card/50 p-4 sm:p-5", + "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; From 9749ddc083d3596c359e83f543e095d14828c57d Mon Sep 17 00:00:00 2001 From: Tshepang Date: Tue, 26 May 2026 18:55:44 +0200 Subject: [PATCH 08/11] fix(marketing): address logo and copy review feedback --- src/pages/concepts/config/conceptsCopy.ts | 17 +++++- src/pages/concepts/config/index.ts | 1 + .../lib/createConceptsPageViewModel.test.ts | 8 +++ .../lib/createConceptsPageViewModel.ts | 2 +- src/pages/concepts/ui/ConceptsPage.test.tsx | 4 +- .../lib/createTutorialPageViewModel.ts | 3 +- src/widgets/marketing-shell/config/index.ts | 8 +++ .../config/marketingLogoConfig.ts | 20 +++++++ .../lib/createRunestoneLogoViewModel.test.ts | 48 +++++++++++++++ .../lib/createRunestoneLogoViewModel.ts | 58 +++++++++++++++++++ src/widgets/marketing-shell/lib/index.ts | 6 ++ src/widgets/marketing-shell/model/index.ts | 1 + .../model/useRunestoneLogo.test.tsx | 28 +++++++++ .../marketing-shell/model/useRunestoneLogo.ts | 6 ++ .../marketing-shell/ui/MarketingHeader.tsx | 5 +- .../ui/MarketingNavigationSheet.tsx | 8 ++- .../marketing-shell/ui/RunestoneLogo.tsx | 43 +++++--------- 17 files changed, 228 insertions(+), 38 deletions(-) create mode 100644 src/widgets/marketing-shell/config/marketingLogoConfig.ts create mode 100644 src/widgets/marketing-shell/lib/createRunestoneLogoViewModel.test.ts create mode 100644 src/widgets/marketing-shell/lib/createRunestoneLogoViewModel.ts create mode 100644 src/widgets/marketing-shell/model/useRunestoneLogo.test.tsx create mode 100644 src/widgets/marketing-shell/model/useRunestoneLogo.ts diff --git a/src/pages/concepts/config/conceptsCopy.ts b/src/pages/concepts/config/conceptsCopy.ts index f81a4db..e6cce7c 100644 --- a/src/pages/concepts/config/conceptsCopy.ts +++ b/src/pages/concepts/config/conceptsCopy.ts @@ -8,7 +8,16 @@ export const CONCEPTS_COPY = { HERO_SUBTITLE: "Runestone maps statechart ideas to dungeon objects so software behavior can be explored spatially.", CTA_HEADING: "Ready to inspect the dungeon?", - CTA_SUBTITLE: "Drop your configuration into the engine.", + CTA_SUBTITLE: "Use the guide as a map, then inspect the concepts in motion.", +} as const; + +export const CONCEPTS_SECTION_IDS = { + ACTOR: "actor-independent-loop", + CONTEXT: "context-inventory-hp-current-room", + EVENT: "event-input-prompt", + GUARD: "guard-locked-door", + STATE: "state-room", + TRANSITION: "transition-corridor", } as const; export const CONCEPTS_MAPPING_TONES = { @@ -40,6 +49,7 @@ export const CONCEPTS_SECTIONS = [ detail: "A state marks the current room or mode, holding the system in one place until conditions change.", iconKey: CONCEPTS_SECTION_ICON_KEYS.STATE, + id: CONCEPTS_SECTION_IDS.STATE, source: "State", target: "Room", tone: CONCEPTS_MAPPING_TONES.ACTIVE, @@ -48,6 +58,7 @@ export const CONCEPTS_SECTIONS = [ detail: "A transition carries the run from one room to the next when an event resolves successfully.", iconKey: CONCEPTS_SECTION_ICON_KEYS.TRANSITION, + id: CONCEPTS_SECTION_IDS.TRANSITION, source: "Transition", target: "Corridor", tone: CONCEPTS_MAPPING_TONES.AVAILABLE, @@ -56,6 +67,7 @@ export const CONCEPTS_SECTIONS = [ detail: "Inputs and prompts trigger evaluation, which can open movement paths or advance other systems.", iconKey: CONCEPTS_SECTION_ICON_KEYS.EVENT, + id: CONCEPTS_SECTION_IDS.EVENT, source: "Event", target: "Input or prompt", tone: CONCEPTS_MAPPING_TONES.AVAILABLE, @@ -64,6 +76,7 @@ export const CONCEPTS_SECTIONS = [ detail: "A guard blocks traversal until the required state, key, or condition is satisfied.", iconKey: CONCEPTS_SECTION_ICON_KEYS.GUARD, + id: CONCEPTS_SECTION_IDS.GUARD, source: "Guard", target: "Locked door", tone: CONCEPTS_MAPPING_TONES.SEALED, @@ -72,6 +85,7 @@ export const CONCEPTS_SECTIONS = [ detail: "Context keeps the values a run depends on, such as inventory, HP, and current room state.", iconKey: CONCEPTS_SECTION_ICON_KEYS.CONTEXT, + id: CONCEPTS_SECTION_IDS.CONTEXT, source: "Context", target: "Inventory, HP, current room", tone: CONCEPTS_MAPPING_TONES.AVAILABLE, @@ -80,6 +94,7 @@ export const CONCEPTS_SECTIONS = [ detail: "Actors run in isolated loops so camera, player, or audio behavior can respond independently.", iconKey: CONCEPTS_SECTION_ICON_KEYS.ACTOR, + id: CONCEPTS_SECTION_IDS.ACTOR, source: "Actor", target: "Independent loop", tone: CONCEPTS_MAPPING_TONES.ACTIVE, diff --git a/src/pages/concepts/config/index.ts b/src/pages/concepts/config/index.ts index 86963fd..7e87e75 100644 --- a/src/pages/concepts/config/index.ts +++ b/src/pages/concepts/config/index.ts @@ -2,6 +2,7 @@ export { CONCEPTS_COPY, CONCEPTS_MAPPING_TONE_CLASS_NAMES, CONCEPTS_MAPPING_TONES, + CONCEPTS_SECTION_IDS, CONCEPTS_SECTIONS, CONCEPTS_TITLE_TONE_CLASS_NAMES, type ConceptsMappingTone, diff --git a/src/pages/concepts/lib/createConceptsPageViewModel.test.ts b/src/pages/concepts/lib/createConceptsPageViewModel.test.ts index ea1f3da..c8d655f 100644 --- a/src/pages/concepts/lib/createConceptsPageViewModel.test.ts +++ b/src/pages/concepts/lib/createConceptsPageViewModel.test.ts @@ -8,6 +8,8 @@ import { } from "lucide-react"; import { describe, expect, it, vi } from "vitest"; +import { CONCEPTS_SECTION_IDS } from "../config"; + import { createConceptsPageViewModel } from "./createConceptsPageViewModel"; describe("createConceptsPageViewModel", () => { @@ -21,6 +23,9 @@ describe("createConceptsPageViewModel", () => { expect(viewModel.ctaProps.isAuthenticated).toBe(true); expect(viewModel.ctaProps.onEntryRequest).toBe(handleUsernameEntryRequest); + expect(viewModel.mappingSectionProps.sections[0]?.id).toBe( + CONCEPTS_SECTION_IDS.STATE, + ); expect(viewModel.mappingSectionProps.sections[0]?.icon).toBe(DoorOpen); expect(viewModel.mappingSectionProps.sections[1]?.icon).toBe( ArrowRightLeft, @@ -34,5 +39,8 @@ describe("createConceptsPageViewModel", () => { expect(viewModel.mappingSectionProps.sections[3]?.titleClassName).toBe( "text-dungeon-rune-sealed", ); + expect(viewModel.mappingSectionProps.sections[3]?.id).toBe( + CONCEPTS_SECTION_IDS.GUARD, + ); }); }); diff --git a/src/pages/concepts/lib/createConceptsPageViewModel.ts b/src/pages/concepts/lib/createConceptsPageViewModel.ts index fc2778d..ff9d941 100644 --- a/src/pages/concepts/lib/createConceptsPageViewModel.ts +++ b/src/pages/concepts/lib/createConceptsPageViewModel.ts @@ -53,7 +53,7 @@ export const createConceptsPageViewModel = ({ detail: section.detail, icon: resolveConceptsIcon(section.iconKey), iconClassName: CONCEPTS_MAPPING_TONE_CLASS_NAMES[section.tone], - id: `${section.source}-${section.target}`, + id: section.id, isSealed: section.tone === CONCEPTS_MAPPING_TONES.SEALED, title: section.target, titleClassName: CONCEPTS_TITLE_TONE_CLASS_NAMES[section.tone], diff --git a/src/pages/concepts/ui/ConceptsPage.test.tsx b/src/pages/concepts/ui/ConceptsPage.test.tsx index fa515a4..e842db1 100644 --- a/src/pages/concepts/ui/ConceptsPage.test.tsx +++ b/src/pages/concepts/ui/ConceptsPage.test.tsx @@ -48,7 +48,9 @@ describe("ConceptsPage", () => { expect(screen.getByText("Independent loop")).not.toBeNull(); expect(screen.getByText("Ready to inspect the dungeon?")).not.toBeNull(); expect( - screen.getByText("Drop your configuration into the engine."), + screen.getByText( + "Use the guide as a map, then inspect the concepts in motion.", + ), ).not.toBeNull(); expect( screen.getByText( diff --git a/src/pages/tutorial/lib/createTutorialPageViewModel.ts b/src/pages/tutorial/lib/createTutorialPageViewModel.ts index 366250b..ffab28d 100644 --- a/src/pages/tutorial/lib/createTutorialPageViewModel.ts +++ b/src/pages/tutorial/lib/createTutorialPageViewModel.ts @@ -6,6 +6,7 @@ import { TUTORIAL_CONTROLS_COPY, TUTORIAL_FIRST_RUN_COPY, TUTORIAL_FIRST_RUN_STEPS, + type TutorialControlTone, } from "../config"; import { resolveTutorialIcon } from "./resolveTutorialIcon"; @@ -25,7 +26,7 @@ export type TutorialControlRowViewModel = { export type TutorialControlGroupViewModel = { heading: string; rows: readonly TutorialControlRowViewModel[]; - tone: "accent" | "primary"; + tone: TutorialControlTone; }; type TutorialPageViewModel = { diff --git a/src/widgets/marketing-shell/config/index.ts b/src/widgets/marketing-shell/config/index.ts index 3a51174..308fa42 100644 --- a/src/widgets/marketing-shell/config/index.ts +++ b/src/widgets/marketing-shell/config/index.ts @@ -1,5 +1,13 @@ 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, diff --git a/src/widgets/marketing-shell/config/marketingLogoConfig.ts b/src/widgets/marketing-shell/config/marketingLogoConfig.ts new file mode 100644 index 0000000..56437b8 --- /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/lib/createRunestoneLogoViewModel.test.ts b/src/widgets/marketing-shell/lib/createRunestoneLogoViewModel.test.ts new file mode 100644 index 0000000..eb1301d --- /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 0000000..c5a33a4 --- /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 index ea26b02..0bd561e 100644 --- a/src/widgets/marketing-shell/lib/index.ts +++ b/src/widgets/marketing-shell/lib/index.ts @@ -5,4 +5,10 @@ export { 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/model/index.ts b/src/widgets/marketing-shell/model/index.ts index bd1126e..d165d81 100644 --- a/src/widgets/marketing-shell/model/index.ts +++ b/src/widgets/marketing-shell/model/index.ts @@ -1,2 +1,3 @@ export { useMarketingLayoutRoute } from "./useMarketingLayoutRoute"; export { useMarketingNavigation } from "./useMarketingNavigation"; +export { useRunestoneLogo } from "./useRunestoneLogo"; 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 0000000..b708b18 --- /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 0000000..37b064f --- /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/MarketingHeader.tsx b/src/widgets/marketing-shell/ui/MarketingHeader.tsx index 6ab867f..88091fe 100644 --- a/src/widgets/marketing-shell/ui/MarketingHeader.tsx +++ b/src/widgets/marketing-shell/ui/MarketingHeader.tsx @@ -8,6 +8,7 @@ import { MARKETING_LAYOUT_CLASS_NAMES, MARKETING_ROUTES, MARKETING_SHELL_COPY, + RUNESTONE_LOGO_VARIANTS, } from "../config"; import type { MarketingNavigationViewModel } from "../lib"; import { MarketingNavigationSheet } from "./MarketingNavigationSheet"; @@ -33,10 +34,10 @@ export function MarketingHeader({ )} >
- +
- +