Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ Once a commit is triggered, the Lefthook orchestrator executes four mandatory ch
* **Biome Static Analysis**: Audits and formats code in milliseconds to enforce strict formatting guidelines and prevent styling anti-patterns.
* **Steiger FSD Architectural Bounds**: Validates Feature-Sliced Design layers (`app -> pages -> widgets -> features -> entities -> shared`), blocking illegal circular dependencies and ensuring clean vertical slice separation.
* **Segment Purity Validator**: Enforces a strict separation of concerns, ensuring static data remain isolated in `config/` folders while functional utilities are placed exclusively in `lib/` modules.
* **Vitest Coverage Suite**: Automatically runs our 900+ unit and integration tests on every integration attempt to prevent structural regressions.
* **Vitest Coverage Suite**: Automatically runs our 1,000+ unit and integration tests on every integration attempt to prevent structural regressions.

### Spec-Driven Development (SDD)
Before any subsystem is implemented, a comprehensive technical blueprint is created. This specification details exact scope boundaries, architectural constraints, edge cases, and expected acceptance metrics, guiding implementing agents and maintaining code integrity.
Expand Down
Binary file added public/marketing/game-running-bg.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 26 additions & 0 deletions public/marketing/hex-grid.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions src/app/routes/_marketing.concepts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";

import { ConceptsPage } from "@/pages/concepts";

export const Route = createFileRoute("/_marketing/concepts")({
component: ConceptsPage,
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ import { createFileRoute } from "@tanstack/react-router";

import { HomePage } from "@/pages/home";

export const Route = createFileRoute("/")({
export const Route = createFileRoute("/_marketing/")({
component: HomePage,
});
25 changes: 25 additions & 0 deletions src/app/routes/_marketing.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { createFileRoute, Outlet } from "@tanstack/react-router";

import { UsernameModal } from "@/features/auth";
import {
MarketingPageFrame,
MarketingShell,
useMarketingLayoutRoute,
} from "@/widgets/marketing-shell";

export const Route = createFileRoute("/_marketing")({
component: MarketingLayoutRoute,
});

function MarketingLayoutRoute() {
const { shellProps, usernameModalProps } = useMarketingLayoutRoute();

return (
<MarketingShell {...shellProps}>
<MarketingPageFrame>
<Outlet />
</MarketingPageFrame>
<UsernameModal {...usernameModalProps} />
</MarketingShell>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ import { createFileRoute } from "@tanstack/react-router";

import { TutorialPage } from "@/pages/tutorial";

export const Route = createFileRoute("/tutorial")({
export const Route = createFileRoute("/_marketing/tutorial")({
component: TutorialPage,
});
8 changes: 6 additions & 2 deletions src/app/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,12 @@

html,
body {
height: 100%;
overflow: hidden;
min-height: 100%;
overflow-x: hidden;
}

html {
scrollbar-gutter: stable;
}

body {
Expand Down
1 change: 1 addition & 0 deletions src/features/auth/config/authConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const AUTH_COPY = {
MODAL_TITLE: "Enter the dungeon",
MODAL_DESCRIPTION:
"Use the suggested name or edit it before saving your progress and leaderboard entry.",
MODAL_KEEP_READING_LABEL: "Keep reading",
USERNAME_LABEL: "Name",
USERNAME_PLACEHOLDER: "Your name",
USERNAME_SUBMIT_LABEL: "Enter the dungeon",
Expand Down
2 changes: 2 additions & 0 deletions src/features/auth/config/authEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export const AUTH_EVENTS = {
USERNAME_SUBMIT_REQUESTED: "USERNAME_SUBMIT_REQUESTED",
USERNAME_SUBMIT_SUCCEEDED: "USERNAME_SUBMIT_SUCCEEDED",
USERNAME_SUBMIT_FAILED: "USERNAME_SUBMIT_FAILED",
USERNAME_ENTRY_REQUESTED: "USERNAME_ENTRY_REQUESTED",
USERNAME_ENTRY_DEFERRED: "USERNAME_ENTRY_DEFERRED",
SIGN_OUT_REQUESTED: "SIGN_OUT_REQUESTED",
} as const;

Expand Down
4 changes: 4 additions & 0 deletions src/features/auth/config/authMachineConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ export const AUTH_INITIAL_CONTEXT = {
profile: null,
pendingUsername: null,
errorMessage: null,
isUsernameEntryRequested: false,
isUsernameEntryDeferred: false,
};

export const AUTH_CONTEXT_KEYS = {
UUID: "uuid",
PROFILE: "profile",
PENDING_USERNAME: "pendingUsername",
ERROR_MESSAGE: "errorMessage",
IS_USERNAME_ENTRY_REQUESTED: "isUsernameEntryRequested",
IS_USERNAME_ENTRY_DEFERRED: "isUsernameEntryDeferred",
} as const;
70 changes: 70 additions & 0 deletions src/features/auth/lib/createAuthContextValue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const createAuthSnapshot = ({
context: {
profile,
errorMessage,
isUsernameEntryRequested: false,
isUsernameEntryDeferred: false,
},
matches: (status: string) => matchedStates.includes(status),
});
Expand All @@ -38,6 +40,8 @@ describe("createAuthContextValue", () => {
it("builds authenticated context state with ready label", () => {
const handleUsernameFormSubmit = vi.fn();
const handleSessionBootstrapRetry = vi.fn();
const handleUsernameEntryRequest = vi.fn();
const handleUsernameEntryDismiss = vi.fn();
const snapshot = createAuthSnapshot({
value: AUTH_STATUS.AUTHENTICATED,
profile: TEST_USER_PROFILE,
Expand All @@ -50,6 +54,8 @@ describe("createAuthContextValue", () => {
suggestedUsername: "Rune_AshBearAAAA",
handleUsernameFormSubmit,
handleSessionBootstrapRetry,
handleUsernameEntryRequest,
handleUsernameEntryDismiss,
});

expect(authContextValue.authStatus).toBe(AUTH_STATUS.AUTHENTICATED);
Expand All @@ -66,6 +72,12 @@ describe("createAuthContextValue", () => {
expect(authContextValue.handleSessionBootstrapRetry).toBe(
handleSessionBootstrapRetry,
);
expect(authContextValue.handleUsernameEntryRequest).toBe(
handleUsernameEntryRequest,
);
expect(authContextValue.handleUsernameEntryDismiss).toBe(
handleUsernameEntryDismiss,
);
});

it("opens username modal for requires-username and submitting states", () => {
Expand All @@ -79,6 +91,8 @@ describe("createAuthContextValue", () => {
suggestedUsername: "Rune_AshBearAAAA",
handleUsernameFormSubmit: vi.fn(),
handleSessionBootstrapRetry: vi.fn(),
handleUsernameEntryRequest: vi.fn(),
handleUsernameEntryDismiss: vi.fn(),
});

expect(requiresUsernameContext.isUsernameModalOpen).toBe(true);
Expand All @@ -95,9 +109,65 @@ describe("createAuthContextValue", () => {
suggestedUsername: "Rune_AshBearAAAA",
handleUsernameFormSubmit: vi.fn(),
handleSessionBootstrapRetry: vi.fn(),
handleUsernameEntryRequest: vi.fn(),
handleUsernameEntryDismiss: vi.fn(),
});

expect(submittingContext.isUsernameSubmitting).toBe(true);
expect(submittingContext.isUsernameModalOpen).toBe(true);
});

it("opens username modal when entry is requested during session checking", () => {
const authContextValue = createAuthContextValue({
snapshot: {
...createAuthSnapshot({
value: AUTH_STATUS.CHECKING_SESSION,
profile: null,
errorMessage: null,
matchedStates: [AUTH_STATUS.CHECKING_SESSION],
}),
context: {
profile: null,
errorMessage: null,
isUsernameEntryRequested: true,
isUsernameEntryDeferred: false,
},
},
suggestedUsername: "Rune_AshBearAAAA",
handleUsernameFormSubmit: vi.fn(),
handleSessionBootstrapRetry: vi.fn(),
handleUsernameEntryRequest: vi.fn(),
handleUsernameEntryDismiss: vi.fn(),
});

expect(authContextValue.isCheckingSession).toBe(true);
expect(authContextValue.isUsernameModalOpen).toBe(true);
});

it("keeps the username modal closed when entry is deferred", () => {
const authContextValue = createAuthContextValue({
snapshot: {
...createAuthSnapshot({
value: AUTH_STATUS.REQUIRES_USERNAME,
profile: null,
errorMessage: null,
matchedStates: [AUTH_STATUS.REQUIRES_USERNAME],
}),
context: {
profile: null,
errorMessage: null,
isUsernameEntryRequested: false,
isUsernameEntryDeferred: true,
},
},
suggestedUsername: "Rune_AshBearAAAA",
handleUsernameFormSubmit: vi.fn(),
handleSessionBootstrapRetry: vi.fn(),
handleUsernameEntryRequest: vi.fn(),
handleUsernameEntryDismiss: vi.fn(),
});

expect(authContextValue.isUsernameModalOpen).toBe(false);
expect(authContextValue.isAuthenticated).toBe(false);
});
});
15 changes: 14 additions & 1 deletion src/features/auth/lib/createAuthContextValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ type AuthSnapshotLike = {
context: {
profile: UserProfile | null;
errorMessage: string | null;
isUsernameEntryRequested: boolean;
isUsernameEntryDeferred: boolean;
};
matches: (status: AuthStatusValue) => boolean;
};
Expand All @@ -20,13 +22,17 @@ type CreateAuthContextValueInput = {
suggestedUsername: string;
handleSessionBootstrapRetry: AuthContextValue["handleSessionBootstrapRetry"];
handleUsernameFormSubmit: AuthContextValue["handleUsernameFormSubmit"];
handleUsernameEntryRequest: AuthContextValue["handleUsernameEntryRequest"];
handleUsernameEntryDismiss: AuthContextValue["handleUsernameEntryDismiss"];
};

const createAuthContextValue = ({
snapshot,
suggestedUsername,
handleSessionBootstrapRetry,
handleUsernameFormSubmit,
handleUsernameEntryRequest,
handleUsernameEntryDismiss,
}: CreateAuthContextValueInput): AuthContextValue => {
const isCheckingSession = snapshot.matches(AUTH_STATUS.CHECKING_SESSION);
const isAuthenticated = snapshot.matches(AUTH_STATUS.AUTHENTICATED);
Expand All @@ -41,7 +47,12 @@ const createAuthContextValue = ({
isCheckingSession,
isAuthenticated,
isUsernameModalOpen:
snapshot.matches(AUTH_STATUS.REQUIRES_USERNAME) || isUsernameSubmitting,
(snapshot.matches(AUTH_STATUS.CHECKING_SESSION) &&
snapshot.context.isUsernameEntryRequested &&
!snapshot.context.isUsernameEntryDeferred) ||
(snapshot.matches(AUTH_STATUS.REQUIRES_USERNAME) &&
!snapshot.context.isUsernameEntryDeferred) ||
isUsernameSubmitting,
isUsernameSubmitting,
readyStatusLabel: snapshot.context.profile
? formatUserDisplayTag(
Expand All @@ -50,6 +61,8 @@ const createAuthContextValue = ({
)
: null,
suggestedUsername,
handleUsernameEntryRequest,
handleUsernameEntryDismiss,
handleSessionBootstrapRetry,
handleUsernameFormSubmit,
};
Expand Down
44 changes: 44 additions & 0 deletions src/features/auth/model/authMachine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,4 +132,48 @@ describe("authMachine", () => {
"unable to create user",
);
});

it("defers and reopens the username entry without authenticating", () => {
const actor = createActor(authMachine).start();

actor.send({
type: AUTH_EVENTS.SESSION_BOOTSTRAPPED,
uuid: "session-uuid",
profile: null,
});
actor.send({
type: AUTH_EVENTS.USERNAME_ENTRY_DEFERRED,
});

expect(actor.getSnapshot().value).toBe(AUTH_STATUS.REQUIRES_USERNAME);
expect(actor.getSnapshot().context.profile).toBeNull();
expect(actor.getSnapshot().context.isUsernameEntryDeferred).toBe(true);

actor.send({
type: AUTH_EVENTS.USERNAME_ENTRY_REQUESTED,
});

expect(actor.getSnapshot().value).toBe(AUTH_STATUS.REQUIRES_USERNAME);
expect(actor.getSnapshot().context.isUsernameEntryDeferred).toBe(false);
});

it("can request username entry while session bootstrap is still checking", () => {
const actor = createActor(authMachine).start();

actor.send({
type: AUTH_EVENTS.USERNAME_ENTRY_REQUESTED,
});

expect(actor.getSnapshot().value).toBe(AUTH_STATUS.CHECKING_SESSION);
expect(actor.getSnapshot().context.isUsernameEntryRequested).toBe(true);
expect(actor.getSnapshot().context.isUsernameEntryDeferred).toBe(false);

actor.send({
type: AUTH_EVENTS.USERNAME_ENTRY_DEFERRED,
});

expect(actor.getSnapshot().value).toBe(AUTH_STATUS.CHECKING_SESSION);
expect(actor.getSnapshot().context.isUsernameEntryRequested).toBe(false);
expect(actor.getSnapshot().context.isUsernameEntryDeferred).toBe(true);
});
});
Loading
Loading