diff --git a/.env.example b/.env.example
index b7957baa..ef3afc1e 100644
--- a/.env.example
+++ b/.env.example
@@ -1,22 +1,33 @@
-VITE_BACKEND_URL=/api
-# UTM settings for shared profile links (optional)
-# Set any of these to enable UTM parameters on shared profile URLs.
-VITE_UTM_SOURCE=twitter
-VITE_UTM_MEDIUM=social
-VITE_UTM_CAMPAIGN=share_profile
-#VITE_UTM_TERM=
-#VITE_UTM_CONTENT=
+# Access Layer Client — environment variables
+# Copy this file to `.env` and adjust values as needed:
+# cp .env.example .env
+# All client-exposed variables must be prefixed with VITE_ (see https://vitejs.dev/guide/env-and-mode).
+# See CONTRIBUTING.md ("Environment variables") for which vars are required vs optional.
+
+# --- Required (sensible defaults provided) ---
+
+# Base URL for the backend API. Use the local backend during development.
VITE_BACKEND_URL=http://localhost:3000/api/v1
+
+# Chain ID selected by default on load. 84532 = Base Sepolia testnet.
VITE_DEFAULT_CHAIN_ID=84532
+
+# RPC URL for a local Anvil node (chain 31337). Used when developing against a local chain.
VITE_ANVIL_RPC_URL=http://127.0.0.1:8545
+
+# RPC URL for the Base Sepolia testnet (chain 84532). The public default works out of the box.
VITE_BASE_SEPOLIA_RPC_URL=https://sepolia.base.org
+
+# --- Optional ---
+
+# RPC URL for the Ethereum Sepolia testnet (chain 11155111). Get one from Alchemy, Infura, or another provider.
VITE_SEPOLIA_RPC_URL=
+
+# RPC URL for Ethereum mainnet (chain 1). Only needed when testing against mainnet.
VITE_MAINNET_RPC_URL=
-# UTM settings for shared profile links (optional)
-# Set any of these to enable UTM parameters on shared profile URLs.
-# Remove or comment out to disable UTM tracking.
-# Example configuration:
+# UTM parameters appended to shared profile links (optional).
+# Set any of these to enable UTM tracking; remove or leave blank to disable.
VITE_UTM_SOURCE=accesslayer
VITE_UTM_MEDIUM=share
VITE_UTM_CAMPAIGN=profile-sharing
diff --git a/.gitignore b/.gitignore
index d4f78a58..32ceb457 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,4 +26,17 @@ dist-ssr
*.sln
*.sw?
issue.md
-pr.md
\ No newline at end of file
+pr.md
+
+# Test artifacts
+__snapshots__
+*.snap
+coverage
+.nyc_output
+
+# OS artifacts
+Thumbs.db
+
+# Lock files from other package managers
+package-lock.json
+yarn.lock
\ No newline at end of file
diff --git a/.kiro/specs/holder-count-cache-invalidation-test/.config.kiro b/.kiro/specs/holder-count-cache-invalidation-test/.config.kiro
new file mode 100644
index 00000000..908762a0
--- /dev/null
+++ b/.kiro/specs/holder-count-cache-invalidation-test/.config.kiro
@@ -0,0 +1 @@
+{"specId": "a3f82c1e-9d47-4b8e-bc63-7e5a2f3d1094", "workflowType": "requirements-first", "specType": "feature"}
diff --git a/.kiro/specs/holder-count-cache-invalidation-test/design.md b/.kiro/specs/holder-count-cache-invalidation-test/design.md
new file mode 100644
index 00000000..90fd7a82
--- /dev/null
+++ b/.kiro/specs/holder-count-cache-invalidation-test/design.md
@@ -0,0 +1,439 @@
+# Design Document
+
+## Feature: Holder Count Cache Invalidation Test
+
+## Overview
+
+This design covers the test infrastructure and minimal production code change needed to validate that the creator detail page's "Audience" holder count chip updates correctly after a React Query cache invalidation triggers a refetch — all within the same mounted component instance, without a page reload.
+
+The production change is small and surgical: extract the holder count value into a `useCreatorHolderCount` custom hook backed by `useQuery`. This makes the component's data dependency explicit and directly testable via React Query's cache API. The integration test then wraps the component with a fresh `QueryClientProvider`, pre-seeds the cache, invalidates the query key, and asserts the updated count appears.
+
+**Key constraints:**
+
+- The test must confirm the update happens within the same mounted component instance (no remount).
+- Each test uses a fresh `QueryClient` instance to prevent inter-test cache contamination.
+- No production network layer (`courseService`) is imported in the test file; all I/O is replaced by `vi.fn()` stubs.
+
+---
+
+## Architecture
+
+The feature involves three layers:
+
+```
+┌──────────────────────────────────────────────────────────────────────────┐
+│ Test Layer (src/pages/__tests__/holderCountCacheInvalidation.test.tsx) │
+│ - Fresh QueryClient per test (beforeEach) │
+│ - vi.fn() mockFetch stub │
+│ - queryClient.setQueryData() to pre-seed cache │
+│ - queryClient.invalidateQueries() to trigger refetch │
+│ - @testing-library/react assertions on MiniStatChip value │
+└─────────────────────┬────────────────────────────────────────────────────┘
+ │ renders
+┌─────────────────────▼────────────────────────────────────────────────────┐
+│ Component Under Test: FeaturedCreatorAudienceChip │
+│ (src/components/common/FeaturedCreatorAudienceChip.tsx) │
+│ - Calls useCreatorHolderCount(creatorId) │
+│ - Renders │
+└─────────────────────┬────────────────────────────────────────────────────┘
+ │ uses
+┌─────────────────────▼────────────────────────────────────────────────────┐
+│ Hook: useCreatorHolderCount │
+│ (src/hooks/useCreatorHolderCount.ts) │
+│ - useQuery({ queryKey: ['creator', creatorId, 'holderCount'], ... }) │
+│ - Returns { count: number | null, isLoading, isError } │
+└──────────────────────────────────────────────────────────────────────────┘
+```
+
+The existing `LandingPage.tsx` continues to work unchanged for users — it renders the same `MiniStatChip` via the new `FeaturedCreatorAudienceChip` component, which replaces the inline constant-backed chip. This keeps the diff minimal and avoids touching unrelated LandingPage logic.
+
+---
+
+## Components and Interfaces
+
+### 1. `useCreatorHolderCount` hook
+
+**File:** `src/hooks/useCreatorHolderCount.ts`
+
+```typescript
+import { useQuery } from '@tanstack/react-query';
+
+export interface HolderCountResult {
+ count: number | null;
+ isLoading: boolean;
+ isError: boolean;
+}
+
+/**
+ * Fetches the holder count for a given creator via React Query.
+ * Query key: ['creator', creatorId, 'holderCount']
+ *
+ * The queryFn is injected as a parameter so tests can supply a mock
+ * without module-level vi.mock() patching.
+ */
+export function useCreatorHolderCount(
+ creatorId: string,
+ fetchHolderCount: (id: string) => Promise
+): HolderCountResult {
+ const { data, isLoading, isError } = useQuery({
+ queryKey: ['creator', creatorId, 'holderCount'],
+ queryFn: () => fetchHolderCount(creatorId),
+ staleTime: 30_000,
+ });
+
+ return {
+ count: data ?? null,
+ isLoading,
+ isError,
+ };
+}
+```
+
+**Design decision — injected `fetchHolderCount`:** Rather than importing a service at module level, the fetch function is a parameter. This means tests pass `vi.fn()` directly as a prop, making the hook trivially mockable without `vi.mock()` hoisting. Production callers pass in the real service method.
+
+### 2. `FeaturedCreatorAudienceChip` component
+
+**File:** `src/components/common/FeaturedCreatorAudienceChip.tsx`
+
+```typescript
+import MiniStatChip from '@/components/common/MiniStatChip';
+import { useCreatorHolderCount } from '@/hooks/useCreatorHolderCount';
+import { getFeaturedCreatorKeyHolderCopy } from '@/utils/holderCount.utils';
+
+interface FeaturedCreatorAudienceChipProps {
+ creatorId: string;
+ fetchHolderCount: (id: string) => Promise;
+}
+
+export function FeaturedCreatorAudienceChip({
+ creatorId,
+ fetchHolderCount,
+}: FeaturedCreatorAudienceChipProps) {
+ const { count } = useCreatorHolderCount(creatorId, fetchHolderCount);
+ const copy = getFeaturedCreatorKeyHolderCopy(count);
+
+ return (
+
+ );
+}
+```
+
+### 3. `getFeaturedCreatorKeyHolderCopy` utility extraction
+
+The existing inline function in `LandingPage.tsx` is moved to a shared utility so both the component and the test can import it:
+
+**File:** `src/utils/holderCount.utils.ts`
+
+```typescript
+import { formatCompactNumber } from '@/utils/numberFormat.utils';
+
+export interface HolderCountCopy {
+ value: string;
+ explanation: string;
+}
+
+export function getFeaturedCreatorKeyHolderCopy(
+ count: number | null | undefined
+): HolderCountCopy {
+ if (count == null) {
+ return {
+ value: 'Key holders unavailable',
+ explanation: 'Key holder data is not available yet.',
+ };
+ }
+ if (count === 0) {
+ return {
+ value: 'No key holders yet',
+ explanation:
+ 'This creator has not unlocked any key holders yet. Be the first to buy a key and start the collector base.',
+ };
+ }
+ return {
+ value: `${formatCompactNumber(count)} key holders`,
+ explanation: 'Number of wallets that currently hold at least one key.',
+ };
+}
+```
+
+### 4. `LandingPage.tsx` integration point
+
+Replace the inline `MiniStatChip` for "Audience" with the new component:
+
+```tsx
+// Before
+
+
+// After
+
+```
+
+Where `realFetchHolderCount` is a thin wrapper over the eventual API call (currently returns `Promise.resolve(FEATURED_CREATOR_KEY_HOLDER_COUNT)` until the real endpoint exists).
+
+### 5. Test `createWrapper` helper
+
+**File:** `src/pages/__tests__/holderCountCacheInvalidation.test.tsx`
+
+```typescript
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MemoryRouter } from 'react-router';
+import type { ReactNode } from 'react';
+
+function createWrapper(queryClient: QueryClient) {
+ return function Wrapper({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+ };
+}
+```
+
+---
+
+## Data Models
+
+### Cache entry shape
+
+```typescript
+// Query key tuple
+type HolderCountKey = ['creator', string, 'holderCount'];
+
+// Cached value type
+type HolderCountData = number | null;
+```
+
+Pre-seeding in tests uses `queryClient.setQueryData`:
+
+```typescript
+queryClient.setQueryData(
+ ['creator', CREATOR_ID, 'holderCount'],
+ initialCount // number | null
+);
+```
+
+### Mock fetch shape
+
+```typescript
+const mockFetchHolderCount = vi.fn<(id: string) => Promise>();
+```
+
+---
+
+## Correctness Properties
+
+_A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees._
+
+The project already has `fast-check` installed as a dev dependency (`"fast-check": "^4.6.0"` in `package.json`), which will be used for all property-based tests below. Each property test runs a minimum of 100 iterations.
+
+---
+
+### Property 1: Initial render round-trip
+
+_For any_ non-negative integer `initialCount`, when the React Query cache is pre-seeded with that count and the component renders without a network call, the DOM shall display exactly the string `getFeaturedCreatorKeyHolderCopy(initialCount).value`.
+
+**Validates: Requirements 1.1, 5.4**
+
+---
+
+### Property 2: Stale-while-revalidate display stability
+
+_For any_ non-negative integer `initialCount`, while the invalidation-triggered refetch is in-flight (the mock fetch has not yet resolved), the DOM shall continue to display the formatted string derived from `initialCount` and shall not show a blank value or an error state.
+
+**Validates: Requirements 2.3**
+
+---
+
+### Property 3: Post-invalidation update round-trip
+
+_For any_ pair of distinct non-negative integers `(initialCount, updatedCount)`, after the cache is pre-seeded with `initialCount`, `queryClient.invalidateQueries` is called, and the mock refetch resolves with `updatedCount`, the DOM shall display `getFeaturedCreatorKeyHolderCopy(updatedCount).value`, shall no longer display `getFeaturedCreatorKeyHolderCopy(initialCount).value`, and this transition shall occur within the same mounted component instance (no unmount–remount cycle).
+
+**Validates: Requirements 3.1, 3.2, 3.4**
+
+---
+
+### Property 4: Format function round-trip
+
+_For any_ non-negative integer `n`, the string `getFeaturedCreatorKeyHolderCopy(n).value` shall equal `"No key holders yet"` when `n === 0`, or `formatCompactNumber(n) + " key holders"` when `n > 0` — and this value shall be identical to what the `FeaturedCreatorAudienceChip` renders in the DOM when seeded with `n`.
+
+**Validates: Requirements 5.1, 5.4**
+
+---
+
+## Error Handling
+
+| Scenario | Behavior |
+| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
+| `fetchHolderCount` rejects | `useCreatorHolderCount` returns `isError: true`, `count: null`; chip displays `"Key holders unavailable"` |
+| `count` is `null` from fetch | Chip displays `"Key holders unavailable"` |
+| `count` is `0` | Chip displays `"No key holders yet"` |
+| `queryClient.invalidateQueries` with non-matching key | No refetch triggered; mock fetch not called; display unchanged |
+| Network timeout during test | Controlled by mock — test resolves or rejects on demand |
+
+The hook does not implement retry logic beyond React Query's defaults (`retry: 3`). For tests, retry is disabled (`retry: false` on the test-scoped `QueryClient`) to keep assertions deterministic.
+
+---
+
+## Testing Strategy
+
+### Overview
+
+This feature uses a **dual testing approach**:
+
+- **Property-based tests** (via `fast-check`) for universal correctness properties — format round-trips, stale-while-revalidate stability, and post-invalidation update guarantees.
+- **Example-based / edge-case tests** for concrete scenarios: zero count, null count, non-matching query key, reload-not-called assertion.
+
+### Test file
+
+**Path:** `src/pages/__tests__/holderCountCacheInvalidation.test.tsx`
+
+### Test setup pattern
+
+```typescript
+import { QueryClient } from '@tanstack/react-query';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen, waitFor, act } from '@testing-library/react';
+import fc from 'fast-check';
+
+const CREATOR_ID = 'test-creator-42';
+
+let queryClient: QueryClient;
+let mockFetchHolderCount: ReturnType;
+
+beforeEach(() => {
+ // Fresh QueryClient per test — retry disabled for determinism
+ queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ mockFetchHolderCount = vi.fn();
+});
+
+afterEach(() => {
+ queryClient.clear();
+});
+```
+
+### Property-based test outline
+
+```typescript
+// Property 1: Initial render round-trip
+it('renders the correct formatted string for any seeded count', async () => {
+ await fc.assert(
+ fc.asyncProperty(fc.integer({ min: 1, max: 1_000_000 }), async count => {
+ // Feature: holder-count-cache-invalidation-test, Property 1:
+ // For any non-negative integer initialCount, DOM displays getFeaturedCreatorKeyHolderCopy(initialCount).value
+ queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ mockFetchHolderCount = vi.fn();
+ queryClient.setQueryData(['creator', CREATOR_ID, 'holderCount'], count);
+
+ const { unmount } = render(
+ ,
+ { wrapper: createWrapper(queryClient) }
+ );
+
+ expect(screen.getByText(getFeaturedCreatorKeyHolderCopy(count).value)).toBeInTheDocument();
+ expect(mockFetchHolderCount).not.toHaveBeenCalled();
+ unmount();
+ }),
+ { numRuns: 100 }
+ );
+});
+```
+
+```typescript
+// Property 3: Post-invalidation update round-trip
+it('displays the updated count after invalidation with the same component instance', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ fc.integer({ min: 1, max: 999 }),
+ fc.integer({ min: 1000, max: 1_000_000 }),
+ async (initialCount, updatedCount) => {
+ // Feature: holder-count-cache-invalidation-test, Property 3:
+ // For any distinct (initialCount, updatedCount), post-invalidation DOM shows updatedCount
+ queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ mockFetchHolderCount = vi.fn().mockResolvedValue(updatedCount);
+ queryClient.setQueryData(['creator', CREATOR_ID, 'holderCount'], initialCount);
+
+ const reloadSpy = vi.spyOn(window.location, 'reload').mockImplementation(() => {});
+ const { unmount } = render(
+ ,
+ { wrapper: createWrapper(queryClient) }
+ );
+
+ await act(async () => {
+ await queryClient.invalidateQueries({ queryKey: ['creator', CREATOR_ID, 'holderCount'] });
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText(getFeaturedCreatorKeyHolderCopy(updatedCount).value)).toBeInTheDocument();
+ });
+ expect(screen.queryByText(getFeaturedCreatorKeyHolderCopy(initialCount).value)).not.toBeInTheDocument();
+ expect(reloadSpy).not.toHaveBeenCalled();
+
+ reloadSpy.mockRestore();
+ unmount();
+ }
+ ),
+ { numRuns: 100 }
+ );
+});
+```
+
+```typescript
+// Property 4: Format function round-trip (pure function — no render needed)
+it('getFeaturedCreatorKeyHolderCopy produces the correct format for all non-negative integers', () => {
+ fc.assert(
+ fc.property(fc.integer({ min: 1, max: 10_000_000 }), count => {
+ // Feature: holder-count-cache-invalidation-test, Property 4:
+ // For any positive integer n, value === formatCompactNumber(n) + " key holders"
+ const { value } = getFeaturedCreatorKeyHolderCopy(count);
+ expect(value).toBe(`${formatCompactNumber(count)} key holders`);
+ }),
+ { numRuns: 200 }
+ );
+});
+```
+
+### Edge-case and example tests
+
+| Test | Classification | Key assertion |
+| ------------------------------------------------- | -------------- | ------------------------------------------------------------------------- |
+| `count = 0` renders "No key holders yet" | EDGE_CASE | `screen.getByText('No key holders yet')` |
+| `count = null` renders "Key holders unavailable" | EDGE_CASE | `screen.getByText('Key holders unavailable')` |
+| Non-matching query key does not call mockFetch | EDGE_CASE | `expect(mockFetchHolderCount).not.toHaveBeenCalled()` |
+| Seeded cache — mock fetch call count is zero | EXAMPLE | `expect(mockFetchHolderCount).not.toHaveBeenCalled()` |
+| Mock fetch called exactly once after invalidation | EXAMPLE | `expect(mockFetchHolderCount).toHaveBeenCalledTimes(1)` with `CREATOR_ID` |
+
+### Vitest configuration
+
+No changes needed to `vitest.config.ts` — existing `jsdom` environment and `@testing-library/jest-dom/vitest` setup are sufficient. `fast-check` is already a dev dependency.
+
+### Mocks required in test file
+
+```typescript
+vi.mock('@/hooks/useNetworkMismatch', () => ({
+ useNetworkMismatch: () => ({
+ isMismatch: false,
+ expectedChainName: 'Stellar Testnet',
+ }),
+}));
+// framer-motion and other heavy dependencies mocked as in LandingPage.keyboard.test.tsx
+// No vi.mock for courseService — it is NOT imported in this test file
+```
diff --git a/.kiro/specs/holder-count-cache-invalidation-test/requirements.md b/.kiro/specs/holder-count-cache-invalidation-test/requirements.md
new file mode 100644
index 00000000..f2450643
--- /dev/null
+++ b/.kiro/specs/holder-count-cache-invalidation-test/requirements.md
@@ -0,0 +1,85 @@
+# Requirements Document
+
+## Introduction
+
+This feature adds an integration test that verifies the creator detail page updates its displayed holder count after a React Query cache invalidation triggers a refetch. The page currently renders a `MiniStatChip` whose "Audience" value is derived from `FEATURED_CREATOR_KEY_HOLDER_COUNT`. The test must confirm that when the cache entry for a creator is invalidated and the refetch resolves with a new value, the UI reflects the updated count without a full page reload.
+
+The scope is purely test infrastructure: no production behaviour changes are required. The test will wrap the component under test with a `QueryClientProvider`, pre-seed the cache with an initial creator payload, then programmatically invalidate the query key and mock the refetch to return an updated holder count. Assertions confirm the new value is visible and the old value is gone.
+
+## Glossary
+
+- **Creator_Detail_Page**: The section of `LandingPage` (and its composing components) that displays creator statistics including the holder count "Audience" chip.
+- **Holder_Count**: The integer representing the number of wallets that hold at least one key for a given creator. Rendered via `getFeaturedCreatorKeyHolderCopy` as a formatted string inside a `MiniStatChip`.
+- **React_Query_Cache**: The in-memory data store managed by `@tanstack/react-query` (v5). Identified by a query key; entries can be invalidated with `queryClient.invalidateQueries`.
+- **Query_Key**: The array used to identify a cache entry, e.g. `['creator', creatorId]`.
+- **QueryClient**: The TanStack Query client instance that owns the cache and coordinates fetches.
+- **QueryClientProvider**: The React context provider that makes a `QueryClient` available to components under test.
+- **Test_Wrapper**: A helper that wraps a component under test with all required providers (`QueryClientProvider`, `MemoryRouter`) so it renders in isolation.
+- **Mock_Fetch**: A `vi.fn()` stub that replaces the real network call, returning controlled data for each invocation.
+- **Invalidation**: The act of marking one or more cache entries as stale, causing React Query to trigger a background refetch on the next render of a subscribed component.
+- **Refetch**: The background network request that React Query fires after invalidation; in tests this is fulfilled by the `Mock_Fetch`.
+
+## Requirements
+
+### Requirement 1: Initial Holder Count Renders Correctly
+
+**User Story:** As a developer running the integration test suite, I want the creator detail page to render the correct initial holder count from the seeded cache, so that the test has a verified baseline before invalidation.
+
+#### Acceptance Criteria
+
+1. WHEN the `Test_Wrapper` renders the creator detail section with a `QueryClient` pre-seeded with `initialCount` keys in the cache entry, THE `Creator_Detail_Page` SHALL display a formatted string derived from `initialCount` (e.g. `"42 key holders"`) in the holder count element.
+2. WHEN the initial render completes without triggering a network call, THE `Mock_Fetch` SHALL have been called zero times.
+3. IF the `initialCount` is `0`, THEN THE `Creator_Detail_Page` SHALL display `"No key holders yet"` in the holder count element.
+4. IF the `initialCount` is `null`, THEN THE `Creator_Detail_Page` SHALL display `"Key holders unavailable"` in the holder count element.
+
+---
+
+### Requirement 2: Cache Invalidation Triggers a Refetch
+
+**User Story:** As a developer running the integration test suite, I want calling `queryClient.invalidateQueries` on the creator query key to trigger exactly one refetch call to the `Mock_Fetch`, so that I can confirm React Query's invalidation mechanism is wired correctly.
+
+#### Acceptance Criteria
+
+1. WHEN `queryClient.invalidateQueries` is called with the creator's `Query_Key`, THE `QueryClient` SHALL mark the cache entry as stale and schedule a background refetch.
+2. WHEN the invalidation-driven refetch executes, THE `Mock_Fetch` SHALL be called exactly once with the creator's identifier as a parameter.
+3. WHILE the refetch is in-flight, THE `Creator_Detail_Page` SHALL continue to display the previously cached holder count without showing a blank or error state.
+4. IF `queryClient.invalidateQueries` is called with a `Query_Key` that does not match any active query, THEN THE `Mock_Fetch` SHALL NOT be called.
+
+---
+
+### Requirement 3: Updated Holder Count Renders After Refetch
+
+**User Story:** As a developer running the integration test suite, I want the creator detail page to display the updated holder count returned by the refetch, so that I can confirm the UI reflects fresh data after cache invalidation.
+
+#### Acceptance Criteria
+
+1. WHEN the refetch resolves with `updatedCount`, THE `Creator_Detail_Page` SHALL display the formatted string derived from `updatedCount` (e.g. `"99 key holders"`) in the holder count element.
+2. WHEN the updated count is visible, THE `Creator_Detail_Page` SHALL NOT display the formatted string that was derived from `initialCount`.
+3. THE `Creator_Detail_Page` SHALL display the updated count without requiring a full page reload (i.e. `window.location.reload` SHALL NOT be called during the test).
+4. WHEN `updatedCount` differs from `initialCount`, THE display transition SHALL occur within the same mounted component instance, confirming no unmount–remount cycle was required.
+
+---
+
+### Requirement 4: Test Isolation and No Side Effects
+
+**User Story:** As a developer running the integration test suite, I want each test case to use a fresh `QueryClient` instance and reset all mocks, so that tests do not leak state into one another.
+
+#### Acceptance Criteria
+
+1. THE `Test_Wrapper` SHALL instantiate a new `QueryClient` in `beforeEach` (or equivalent per-test setup) so that cache state from one test does not influence another.
+2. THE `Mock_Fetch` SHALL be reset (via `vi.resetAllMocks()` or `mockFn.mockReset()`) before each test so that call counts and return values are clean.
+3. WHEN a test completes, THE `Test_Wrapper` SHALL unmount cleanly without leaving dangling subscriptions or timers that could affect subsequent tests.
+4. THE test file SHALL NOT import or call any production network layer (e.g. `courseService`) directly; all external I/O SHALL be replaced by `Mock_Fetch` stubs.
+
+---
+
+### Requirement 5: Holder Count Display Format Consistency
+
+**User Story:** As a developer running the integration test suite, I want the holder count format assertions to match the format produced by `getFeaturedCreatorKeyHolderCopy`, so that the test accurately reflects what a real user would see.
+
+#### Acceptance Criteria
+
+1. THE `Creator_Detail_Page` SHALL format a positive `holderCount` as `" key holders"` where `` is the output of `formatCompactNumber(holderCount)`.
+2. WHEN `holderCount` is `0`, THE `Creator_Detail_Page` SHALL display exactly `"No key holders yet"`.
+3. WHEN `holderCount` is `null` or `undefined`, THE `Creator_Detail_Page` SHALL display exactly `"Key holders unavailable"`.
+4. FOR ALL valid non-negative integer values of `holderCount`, THE display string produced by `getFeaturedCreatorKeyHolderCopy(holderCount)` SHALL be consistent with the string rendered in the DOM (round-trip equivalence property).
diff --git a/.kiro/specs/holder-count-cache-invalidation-test/tasks.md b/.kiro/specs/holder-count-cache-invalidation-test/tasks.md
new file mode 100644
index 00000000..648afeb7
--- /dev/null
+++ b/.kiro/specs/holder-count-cache-invalidation-test/tasks.md
@@ -0,0 +1,107 @@
+# Implementation Plan: Holder Count Cache Invalidation Test
+
+## Overview
+
+Extract the holder count utility and introduce a thin React Query–backed component layer (`useCreatorHolderCount` + `FeaturedCreatorAudienceChip`) so that cache invalidation is directly observable in tests. Write a property-based integration test covering all four correctness properties and the key edge cases, then verify the full suite passes.
+
+The production diff is intentionally small: one utility file, one hook, one component, and a one-line swap in `LandingPage.tsx`. Everything else lives in the test file.
+
+## Tasks
+
+- [x] 1. Extract `getFeaturedCreatorKeyHolderCopy` to a shared utility module
+ - Create `src/utils/holderCount.utils.ts`
+ - Move the `getFeaturedCreatorKeyHolderCopy` function (currently defined inline in `LandingPage.tsx` at line ~81) into the new file
+ - Export `HolderCountCopy` interface and `getFeaturedCreatorKeyHolderCopy` function
+ - Import `formatCompactNumber` from `@/utils/numberFormat.utils`
+ - Keep the existing inline definition in `LandingPage.tsx` for now — it will be replaced in Task 4
+ - _Requirements: 5.1, 5.2, 5.3, 5.4_
+
+- [x] 2. Create `useCreatorHolderCount` hook
+ - Create `src/hooks/useCreatorHolderCount.ts`
+ - Implement `useQuery` with query key `['creator', creatorId, 'holderCount']` and `staleTime: 30_000`
+ - Accept `fetchHolderCount: (id: string) => Promise` as an injected parameter (avoids module-level `vi.mock` in tests)
+ - Export `HolderCountResult` interface `{ count: number | null; isLoading: boolean; isError: boolean }`
+ - Return `{ count: data ?? null, isLoading, isError }`
+ - _Requirements: 2.1, 2.2, 2.3_
+
+- [x] 3. Create `FeaturedCreatorAudienceChip` component
+ - Create `src/components/common/FeaturedCreatorAudienceChip.tsx`
+ - Accept props: `creatorId: string` and `fetchHolderCount: (id: string) => Promise`
+ - Call `useCreatorHolderCount(creatorId, fetchHolderCount)` and pipe `count` through `getFeaturedCreatorKeyHolderCopy`
+ - Render ``
+ - Import `MiniStatChip` from `@/components/common/MiniStatChip`
+ - Import `useCreatorHolderCount` from `@/hooks/useCreatorHolderCount`
+ - Import `getFeaturedCreatorKeyHolderCopy` from `@/utils/holderCount.utils`
+ - _Requirements: 1.1, 1.3, 1.4, 3.1, 3.2, 5.1, 5.2, 5.3_
+
+- [x] 4. Update `LandingPage.tsx` to use `FeaturedCreatorAudienceChip`
+ - Import `FeaturedCreatorAudienceChip` from `@/components/common/FeaturedCreatorAudienceChip`
+ - Replace the inline `` block (lines ~1199–1205) with ``
+ - Pass a `fetchHolderCount` implementation that returns `Promise.resolve(FEATURED_CREATOR_KEY_HOLDER_COUNT)` (preserves existing behaviour until the real endpoint lands)
+ - Remove the now-unused `featuredCreatorKeyHolderCopy` derived variable (line ~560–563) and the inline `getFeaturedCreatorKeyHolderCopy` function definition (lines ~81–100)
+ - Verify `LandingPage.tsx` still compiles and the keyboard test (`LandingPage.keyboard.test.tsx`) still passes
+ - _Requirements: 1.1, 3.4_
+
+- [-] 5. Write the integration test
+ - Create `src/pages/__tests__/holderCountCacheInvalidation.test.tsx`
+ - [-] 5.1 Set up test scaffolding
+ - Import `QueryClient`, `QueryClientProvider` from `@tanstack/react-query`; `MemoryRouter` from `react-router`; `render`, `screen`, `waitFor`, `act` from `@testing-library/react`; `fc` from `fast-check`; `beforeEach`, `afterEach`, `describe`, `expect`, `it`, `vi` from `vitest`
+ - Import `FeaturedCreatorAudienceChip` from `@/components/common/FeaturedCreatorAudienceChip`
+ - Import `getFeaturedCreatorKeyHolderCopy` from `@/utils/holderCount.utils`
+ - Import `formatCompactNumber` from `@/utils/numberFormat.utils`
+ - Add `vi.mock` stubs for `@/hooks/useNetworkMismatch`, `framer-motion`, and any other heavy transitive dependencies pulled in by `FeaturedCreatorAudienceChip` — mirror the pattern from `LandingPage.keyboard.test.tsx`
+ - Define `CREATOR_ID = 'test-creator-42'`; declare `queryClient` and `mockFetchHolderCount` at describe scope
+ - `beforeEach`: create fresh `QueryClient({ defaultOptions: { queries: { retry: false } } })` and reset `mockFetchHolderCount` via `vi.fn()`
+ - `afterEach`: call `queryClient.clear()`
+ - Implement `createWrapper(queryClient)` returning a component that wraps children in `` + ``
+ - _Requirements: 4.1, 4.2, 4.3, 4.4_
+
+ - [~] 5.2 Write property test for Property 1 — initial render round-trip
+ - **Property 1: Initial render round-trip**
+ - **Validates: Requirements 1.1, 5.4**
+ - Use `fc.asyncProperty(fc.integer({ min: 1, max: 1_000_000 }), ...)` with `numRuns: 100`
+ - For each `count`: create fresh `queryClient`, seed with `queryClient.setQueryData(['creator', CREATOR_ID, 'holderCount'], count)`, render `FeaturedCreatorAudienceChip` with wrapper, assert `screen.getByText(getFeaturedCreatorKeyHolderCopy(count).value)` is in the document, assert `mockFetchHolderCount` was NOT called, then `unmount()`
+ - _Requirements: 1.1, 1.2, 5.4_
+
+ - [~] 5.3 Write property test for Property 2 — stale-while-revalidate display stability
+ - **Property 2: Stale-while-revalidate display stability**
+ - **Validates: Requirements 2.3**
+ - Use `fc.asyncProperty(fc.integer({ min: 1, max: 1_000_000 }), ...)` with `numRuns: 100`
+ - For each `initialCount`: seed cache, render component, call `queryClient.invalidateQueries` but do NOT resolve the pending `mockFetchHolderCount` (use a `Promise` that never resolves during the assertion window), assert old value is still visible and no blank/error state
+ - _Requirements: 2.3_
+
+ - [~] 5.4 Write property test for Property 3 — post-invalidation update round-trip
+ - **Property 3: Post-invalidation update round-trip**
+ - **Validates: Requirements 3.1, 3.2, 3.4**
+ - Use `fc.asyncProperty(fc.integer({ min: 1, max: 999 }), fc.integer({ min: 1000, max: 1_000_000 }), ...)` with `numRuns: 100` (disjoint ranges guarantee `initialCount !== updatedCount`)
+ - For each pair `(initialCount, updatedCount)`: seed cache with `initialCount`, render, spy on `window.location.reload`, invalidate query, await `waitFor` assertion that updated text is visible and old text is gone, assert `reloadSpy` was NOT called, `unmount()`
+ - _Requirements: 3.1, 3.2, 3.3, 3.4_
+
+ - [~] 5.5 Write property test for Property 4 — format function round-trip
+ - **Property 4: Format function round-trip**
+ - **Validates: Requirements 5.1, 5.4**
+ - Use synchronous `fc.property(fc.integer({ min: 1, max: 10_000_000 }), ...)` with `numRuns: 200`
+ - For each `n > 0`: assert `getFeaturedCreatorKeyHolderCopy(n).value === formatCompactNumber(n) + ' key holders'`
+ - _Requirements: 5.1, 5.4_
+
+ - [ ]\* 5.6 Write edge-case tests
+ - `count = 0` renders `"No key holders yet"` — seed cache with `0`, render, assert text present
+ - `count = null` renders `"Key holders unavailable"` — seed cache with `null`, render, assert text present
+ - Non-matching query key: invalidate a different key, assert `mockFetchHolderCount` was NOT called and display is unchanged
+ - After invalidation + resolved refetch: assert `mockFetchHolderCount` was called exactly once with `CREATOR_ID`
+ - _Requirements: 1.3, 1.4, 2.2, 2.4_
+
+- [~] 6. Checkpoint — run tests and confirm everything passes
+ - Run `pnpm test` (or `pnpm vitest run`) from `accesslayer-client--fork/`
+ - Confirm `holderCountCacheInvalidation.test.tsx` passes all property and edge-case tests
+ - Confirm `LandingPage.keyboard.test.tsx` still passes (no regression from Task 4 changes)
+ - Fix any TypeScript or test errors surfaced; ask the user if questions arise.
+
+## Notes
+
+- Tasks marked with `*` are optional and can be skipped for a faster MVP
+- Each task references specific requirements for traceability
+- The `fetchHolderCount` injection pattern in the hook and component avoids `vi.mock` hoisting complexity — tests pass `vi.fn()` directly as a prop
+- Property tests use disjoint integer ranges in Property 3 to guarantee `initialCount !== updatedCount` without needing a `fc.filter`
+- `retry: false` on the test-scoped `QueryClient` keeps assertions deterministic
+- `fast-check` v4 (`"^4.6.0"`) is already installed as a dev dependency — no new packages needed
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b606c35f..3d41985d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -12,7 +12,12 @@ Thanks for contributing to the frontend for Access Layer, a Stellar-native creat
## Local setup
1. Install Node.js 20+ and `pnpm`.
-2. Copy `.env.example` to `.env` and add any local values you need.
+2. Copy `.env.example` to `.env` and adjust values as needed (see [Environment variables](#environment-variables)):
+
+ ```bash
+ cp .env.example .env
+ ```
+
3. Install dependencies:
```bash
@@ -25,6 +30,41 @@ pnpm install
pnpm dev
```
+## Environment variables
+
+All client-exposed variables are prefixed with `VITE_` so Vite can expose them to the
+browser. The defaults in `.env.example` are enough to run the client locally — you only
+need to fill in optional values for the networks you actually want to test against.
+Validation lives in [`src/utils/env.utils.ts`](./src/utils/env.utils.ts).
+
+### Required (defaults provided)
+
+| Variable | Description |
+| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
+| `VITE_BACKEND_URL` | Base URL for the backend API. Point this at your local backend during development (e.g. `http://localhost:3000/api/v1`). |
+| `VITE_DEFAULT_CHAIN_ID` | Chain ID selected by default on load. `84532` is Base Sepolia, the recommended testnet. |
+| `VITE_ANVIL_RPC_URL` | RPC URL for a local [Anvil](https://book.getfoundry.sh/anvil/) node (chain `31337`), used when developing against a local chain. |
+| `VITE_BASE_SEPOLIA_RPC_URL` | RPC URL for the Base Sepolia testnet (chain `84532`). The public default `https://sepolia.base.org` works without an account. |
+
+### Optional
+
+| Variable | Description |
+| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
+| `VITE_SEPOLIA_RPC_URL` | RPC URL for the Ethereum Sepolia testnet (chain `11155111`). Only needed when testing on Sepolia. |
+| `VITE_MAINNET_RPC_URL` | RPC URL for Ethereum mainnet (chain `1`). Only needed when testing against mainnet. |
+| `VITE_UTM_SOURCE`, `VITE_UTM_MEDIUM`, `VITE_UTM_CAMPAIGN`, `VITE_UTM_TERM`, `VITE_UTM_CONTENT` | UTM parameters appended to shared profile links. Leave blank to disable UTM tracking. |
+
+### Where to get testnet RPC URLs
+
+- **Base Sepolia** — the public endpoint `https://sepolia.base.org` is preconfigured and
+ needs no account. For higher rate limits, create a free Base Sepolia endpoint at
+ [Alchemy](https://www.alchemy.com/) or [Infura](https://www.infura.io/).
+- **Ethereum Sepolia** — create a free Sepolia endpoint at
+ [Alchemy](https://www.alchemy.com/) or [Infura](https://www.infura.io/), or use a public
+ endpoint from [Chainlist](https://chainlist.org/?testnets=true&search=sepolia).
+- **Local Anvil** — no URL to fetch; run `anvil` from [Foundry](https://book.getfoundry.sh/)
+ and it serves the default `http://127.0.0.1:8545`.
+
## Verification commands
Run these before opening a pull request:
@@ -51,6 +91,47 @@ The repository also uses Husky plus `lint-staged` to run lightweight checks on s
- Do not reintroduce old template-era pages or branding.
- Prefer accessible, keyboard-friendly UI behavior.
- Keep new routes focused and incremental until the main marketplace flows land.
+- See [docs/adding-page-routes.md](./docs/adding-page-routes.md) for how to register a new page, the file naming convention, and the recommended pattern for auth-protected routes.
+- Non-technical contributors can edit marketing page copy without a local setup — see [docs/marketing-page-copy.md](./docs/marketing-page-copy.md).
+
+### Folder structure
+
+- `pages/`: Route-level components (each file maps to a route)
+- `components/`: Reusable UI components and shared component logic
+ - `components/common/`: Application-specific reusable components
+ - `components/ui/`: Low-level UI primitives (from shadcn/ui or similar)
+ - `components/home/`: Home/landing-page specific components
+- `hooks/`: Custom React hooks
+- `utils/` or `lib/`: Pure helper functions and utilities
+- `constants/`: Application constants
+- `contracts/`: Web3 contract ABIs and related logic
+- `assets/`: Static assets (images, icons, etc.)
+
+### Naming conventions
+
+- **Components**: PascalCase (e.g., `CreatorCard.tsx`, `ConnectWalletButton.tsx`)
+- **Hooks**: camelCase, prefixed with `use` (e.g., `useCopySuccessAnnouncement.ts`, `useNetworkMismatch.ts`)
+- **Utilities/helpers**: camelCase (e.g., `formatNumber.ts`)
+- **Constants**: UPPER_SNAKE_CASE (e.g., `MAX_KEY_SUPPLY`)
+
+### Components vs pages: decision guide
+
+Use `pages/` when:
+
+- The component is a top-level route or page entry point
+- It represents a distinct URL path in the application
+
+Use `components/` when:
+
+- The component is reusable across multiple pages or routes
+- It's a self-contained UI piece with a single responsibility
+- It can be tested independently of route context
+
+Keep components co-located in a page file only when:
+
+- They are used exclusively within that single page
+- They are small, helper components that don't make sense outside the page context
+- Extracting them would add unnecessary indirection
## Good first issue guidance
diff --git a/README.md b/README.md
index ac51a1d7..30a54f19 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,9 @@ The client is responsible for:
- `Ctrl/Cmd + Alt + R` refreshes creator list data from the marketplace
page. The shortcut is ignored while focus is inside text inputs,
textareas, selects, or editable text regions.
+- `T` opens the trade panel from the creator profile page. The shortcut is
+ ignored while focus is inside text inputs, textareas, selects, or
+ editable text regions.
## Local setup
@@ -38,6 +41,10 @@ pnpm install
pnpm dev
```
+## Environment variables
+
+See [docs/environment-variables.md](./docs/environment-variables.md).
+
## Verification
```bash
diff --git a/docs/adding-page-routes.md b/docs/adding-page-routes.md
new file mode 100644
index 00000000..519c70de
--- /dev/null
+++ b/docs/adding-page-routes.md
@@ -0,0 +1,273 @@
+# Adding a New Page Route
+
+This guide explains how to add a new route to the Access Layer client. It covers where routes are registered, the file naming convention for page components, and how to mark a route as auth-protected.
+
+---
+
+## Where routes are registered
+
+Every route in the client is declared in a **single source of truth** at the top of `src/App.tsx`. The router is built once with `createBrowserRouter([...])` and passed to ``. To add a new route, append a `{ path, element }` entry to that array.
+
+```tsx
+// src/App.tsx
+import { createBrowserRouter, RouterProvider } from 'react-router';
+import HomePage from './pages/HomePage';
+import NotFoundPage from './pages/NotFoundPage';
+import AboutPage from './pages/AboutPage'; // ← new import
+
+const router = createBrowserRouter([
+ {
+ path: '/',
+ element: ,
+ },
+ {
+ path: '/about', // ← new public route
+ element: ,
+ },
+ {
+ path: '*', // catch-all stays last
+ element: ,
+ },
+]);
+```
+
+Key things to know:
+
+- **Order matters** only for the `*` catch-all — keep it as the **last entry** so it does not shadow real routes.
+- **Imports** for new page components live at the top of `src/App.tsx` alongside the existing ones. Use the relative `'./pages/Page'` path shown above, matching the existing imports.
+- **Do not** create a second router or wrap the app in another ``. The router configured here is the only one.
+- Nested routes for sub-pages (for example `/creators/:handle/keys`) are added the same way — just declare the full pattern on each entry. The current client uses flat routes only.
+
+---
+
+## File naming convention for page components
+
+| What | Convention | Example |
+| ------------------ | ----------------------------------------------- | ------------------------------------- |
+| File location | `src/pages/` | `src/pages/HomePage.tsx` |
+| File name | PascalCase + `Page` suffix | `AboutPage.tsx` |
+| Exported component | Default export of a function named `Page` | `export default function AboutPage()` |
+
+The component itself uses `export default function Page()` — not a named export, and not an arrow const. This keeps imports straightforward and matches every existing page in `src/pages/`:
+
+```
+src/pages/
+├── HomePage.tsx // registered as '/'
+├── MarketingPage.tsx // exists on disk, not yet registered
+├── LandingPage.tsx // exists on disk, not yet registered
+└── NotFoundPage.tsx // registered as '*' (catch-all)
+```
+
+A few pages exist as files but are not currently wired into the router in `src/App.tsx`. They are kept on disk because they are planned routes waiting on the marketplace flows to land. If you need one of them live, follow this guide to register it like any other page.
+
+### What a page component looks like
+
+Top-level page components take **no props**. They own their own layout, fetching, and state. A minimal page is just a function that returns JSX:
+
+```tsx
+// src/pages/AboutPage.tsx
+export default function AboutPage() {
+ return (
+
+
+ About Access Layer
+
+
+ Access Layer is a Stellar-native creator keys marketplace.
+
+
+ );
+}
+```
+
+Conventions to follow:
+
+- **Default export only.** Named exports break the import in `App.tsx`.
+- **One component per file.** Don't lump multiple pages into a single file.
+- **No props.** Reach for URL params via `useParams()` from `react-router` instead of prop drilling.
+- **Accessibility:** wrap content in a single `` landmark and use semantic headings (`
` for the page title).
+- **Match the project styling.** Use the existing `font-grotesque`, `font-jakarta`, and dark-on-blue palette referenced throughout the codebase. Don't introduce new global styles for a single page.
+- **Use the `@/` alias for component imports.** The project-wide path alias `@/` maps to `src/` (configured via Vite + TypeScript path mapping). Use it freely from any component file; reserve short relative paths like `'../pages/Page'` for tight sibling-file imports.
+
+---
+
+## Public vs auth-protected routes
+
+There is **no existing `RequireAuth` / `ProtectedRoute` wrapper** in the codebase yet. Every route registered today is public. When a feature needs auth gating, follow the pattern below — and ship the wrapper component with that feature, since the client doesn't have a standalone auth-guard component yet.
+
+### Recommended pattern
+
+1. Create a small wrapper component, conventionally `src/components/auth/RequireAuth.tsx` (create the `auth/` subfolder if it doesn't exist yet).
+2. Inside the wrapper, check the appropriate auth state — wagmi's `useAccount` for wallet-gated flows, or `authService.isAuthenticated()` for email/login-gated flows.
+3. While the state is resolving, render a lightweight placeholder (the existing `PendingOnboardingPlaceholder` makes a good model).
+4. If unauthenticated, render a redirect or a connect-wallet CTA — do **not** render the protected page.
+5. Wrap the protected page's `element` with the wrapper inside `App.tsx`.
+
+```tsx
+// src/components/auth/RequireAuth.tsx
+import type { ReactNode } from 'react';
+import { useAccount } from 'wagmi';
+import { Navigate, useLocation } from 'react-router';
+import PendingOnboardingPlaceholder from '@/components/common/PendingOnboardingPlaceholder';
+
+interface RequireAuthProps {
+ children: ReactNode;
+}
+
+export default function RequireAuth({ children }: RequireAuthProps) {
+ const { isConnected, isConnecting } = useAccount();
+ const location = useLocation();
+
+ // Show the placeholder while a fresh wallet connection is in flight
+ // so the user isn't redirected to "/" mid-connect. Once it resolves,
+ // isConnected flips to true and we render the protected page below.
+ // Note: isReconnecting is intentionally NOT in this branch — a
+ // reconnect of a prior session keeps the user authenticated, so
+ // rendering the protected page during a reconnect is fine.
+ if (isConnecting) {
+ return ;
+ }
+
+ if (!isConnected) {
+ // Send unauthenticated users back to the homepage while preserving
+ // the path they tried to reach so a future flow can deep-link them
+ // back here after they connect.
+ return ;
+ }
+
+ return <>{children}>;
+}
+```
+
+Then wire the protection in `src/App.tsx` by wrapping the element rather than registering the page directly:
+
+```tsx
+// src/App.tsx
+import RequireAuth from './components/auth/RequireAuth';
+import DashboardPage from './pages/DashboardPage';
+
+const router = createBrowserRouter([
+ { path: '/', element: },
+ {
+ path: '/dashboard',
+ // Public route → element:
+ // Protected route → wrap in :
+ element: (
+
+
+
+ ),
+ },
+ { path: '*', element: },
+]);
+```
+
+### Choosing which auth check to use
+
+| Use case | Check |
+| ------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
+| The page reads or writes Stellar assets (keys, trades, portfolio) | `useAccount().isConnected` from wagmi |
+| The page reads or writes user-profile data via the backend REST API | `authService.isAuthenticated()` |
+| Both | Call both. Render the placeholder until both resolve; redirect if either is false. |
+
+Don't mix the two states in a single component without documenting which is the source of truth for that page — that's the kind of bug that's hard to spot in review.
+
+### Known repo state (read before adding a protected route)
+
+Two pre-existing repo facts you should know before shipping a wagmi-based guard:
+
+1. **`` is not currently mounted above `` in `src/main.tsx`.** As of writing this guide, `main.tsx` renders `` directly inside ``. Any wagmi hook — including `useAccount` inside `RequireAuth` — will throw at runtime because the `WagmiProvider` context is missing. Wiring `` here is an app-level change and should be tracked separately; reference the tracking issue in your route PR description rather than embedding the wiring fix in your route PR.
+2. **Wagmi has a transient `isConnecting` state** while a fresh wallet handshake is in flight. During this window `isConnected` is `false`, but redirecting the user mid-connect would bounce them away. The example above handles this by rendering `PendingOnboardingPlaceholder` for the `isConnecting` branch — copy that pattern verbatim. (Note: `isReconnecting` is _not_ included in that branch — a reconnect of a prior, already-authenticated session keeps the user authenticated, so rendering the protected page during a reconnect is fine and avoids a UX flash.)
+
+If your guard only uses `authService.isAuthenticated()` (no wagmi hooks), neither caveat applies — the helper reads `localStorage` directly.
+
+---
+
+## Worked example — adding a new public page
+
+This walks a contributor end-to-end through adding `AboutPage` at `/about`. The page is public, so no `RequireAuth` wrapper is involved.
+
+### 1. Create the page component
+
+Add a new file at `src/pages/AboutPage.tsx`:
+
+```tsx
+// src/pages/AboutPage.tsx
+import { Link } from 'react-router';
+import { Button } from '@/components/ui/button';
+
+export default function AboutPage() {
+ return (
+
+
+ About Access Layer
+
+
+ Access Layer is a Stellar-native creator keys marketplace built on
+ the open AccessLayer protocol.
+
+
+
+
+
+
+ );
+}
+```
+
+### 2. Register the route
+
+Add the import and a new entry to the router array in `src/App.tsx`:
+
+```tsx
+// src/App.tsx
+import HomePage from './pages/HomePage';
+import NotFoundPage from './pages/NotFoundPage';
+import AboutPage from './pages/AboutPage'; // ← added
+
+const router = createBrowserRouter([
+ { path: '/', element: },
+ { path: '/about', element: }, // ← added
+ { path: '*', element: },
+]);
+```
+
+### 3. Link to it from another page
+
+Open `src/pages/HomePage.tsx`, import `Link`, and add a `` to the new route:
+
+```tsx
+import { Link } from 'react-router';
+
+// inside the JSX you return
+
+ About this project
+;
+```
+
+### 4. Verify locally
+
+```bash
+pnpm dev # visit http://localhost:5173/about
+pnpm lint
+pnpm build
+```
+
+If `pnpm build` succeeds and `/about` renders the page with a working "Back to marketplace" link, you are done.
+
+---
+
+## Key files at a glance
+
+| File | Purpose |
+| ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `src/App.tsx` | The single source of truth for routing — the only place routes are registered. |
+| `src/pages/` | Folder where every page component lives. One file per page, PascalCase + `Page` suffix, default export. |
+| `src/components/auth/RequireAuth.tsx` | The recommended wrapper for auth-protected pages. Create it the first time a protected route is added. |
+| `src/main.tsx` | Mounts `` inside React's `createRoot`. **Currently does not wrap `` in `Web3Provider`** — must be updated the first time a wagmi-based route guard lands. |
+| `src/providers/Web3Provider.tsx` | Provides `WagmiProvider` + `QueryClientProvider`. Any auth wrapper that uses `useAccount` only works after this provider is mounted above the router in `main.tsx`. |
+| `CONTRIBUTING.md` | High-level project conventions — read this alongside this guide. |
+| `docs/api-layer.md` | Sibling guide covering how to add backend/API endpoints. |
+| [docs/shared-components.md](file:///Users/marvellous/Desktop/accesslayer-client/docs/shared-components.md) | Guide to the project's shared UI component library, key props, and styling. |
diff --git a/docs/api-layer.md b/docs/api-layer.md
new file mode 100644
index 00000000..28f50a70
--- /dev/null
+++ b/docs/api-layer.md
@@ -0,0 +1,254 @@
+# Client API Layer Conventions
+
+This document explains how the client's API layer is structured, how errors are handled, and how to add a new server call end-to-end.
+
+---
+
+## Folder structure
+
+All API service files live in `src/services/`:
+
+```
+src/services/
+├── api.service.ts # Base class — all services extend this
+├── auth.service.ts # Authentication endpoints
+└── course.service.ts # Creator / course data endpoints
+```
+
+Each file exports a **singleton instance** of its service class.
+
+---
+
+## File and class naming convention
+
+| What | Convention | Example |
+| ------------------ | --------------------- | ------------------- |
+| File name | `.service.ts` | `wallet.service.ts` |
+| Class name | `Service` | `WalletService` |
+| Exported singleton | `Service` | `walletService` |
+
+Every service class **extends `BaseApiService`** from `api.service.ts`, which provides:
+
+- A pre-configured Axios instance (`this.api`) pointing at `VITE_BACKEND_URL`
+- Automatic token refresh on `401 TOKEN_EXPIRED` responses
+- A shared `handleError(error)` method that normalises any thrown value to `ApiError`
+
+---
+
+## Error handling
+
+Every service method wraps its Axios call in a `try/catch` and re-throws via `this.handleError`:
+
+```ts
+async getWalletHoldings(address: string): Promise {
+ try {
+ const response = await this.api.get>(
+ `/wallets/${address}/holdings`
+ );
+ return response.data.data;
+ } catch (error) {
+ throw this.handleError(error);
+ }
+}
+```
+
+`handleError` always returns an `ApiError` instance with:
+
+| Field | Type | Description |
+| ---------- | ------------------------------- | ------------------------------------------ |
+| `message` | `string` | Human-readable error message |
+| `status` | `number` | HTTP status code; `0` for network failures |
+| `response` | `APIErrorResponse \| undefined` | Full server error payload when available |
+
+Callers can check `error instanceof ApiError` and inspect `error.status` for branching logic.
+
+---
+
+## How to add a new endpoint
+
+### 1. Add the method to the relevant service file
+
+Open `src/services/.service.ts` (or create a new one if the domain is new). Add a method that:
+
+1. Calls `this.api.get/post/patch/delete`
+2. Extracts `response.data.data`
+3. Re-throws any error via `this.handleError`
+
+```ts
+// src/services/wallet.service.ts
+import { BaseApiService, type APIResponse } from './api.service';
+
+export interface Holding {
+ creatorId: string;
+ quantity: number;
+ priceStroops: number;
+}
+
+class WalletService extends BaseApiService {
+ async getHoldings(address: string): Promise {
+ try {
+ const response = await this.api.get>(
+ `/wallets/${address}/holdings`
+ );
+ return response.data.data;
+ } catch (error) {
+ throw this.handleError(error);
+ }
+ }
+}
+
+export const walletService = new WalletService();
+```
+
+### 2. Define a query key in `src/lib/queryKeys.ts`
+
+Add an entry for the new endpoint so all hooks that reference the same data use an identical cache key:
+
+```ts
+// src/lib/queryKeys.ts
+wallet: {
+ holdings: (address: string) => ['wallet', address, 'holdings'] as const,
+ // ...
+},
+```
+
+### 3. Write a React Query hook
+
+`QueryClientProvider` is already wired up in `src/providers/Web3Provider.tsx` — no setup changes needed.
+
+```ts
+// src/hooks/useWalletHoldings.ts
+import { useQuery } from '@tanstack/react-query';
+import { walletService } from '@/services/wallet.service';
+import { queryKeys } from '@/lib/queryKeys';
+
+export function useWalletHoldings(address: string | undefined) {
+ return useQuery({
+ queryKey: queryKeys.wallet.holdings(address ?? ''),
+ queryFn: () => walletService.getHoldings(address!),
+ enabled: Boolean(address),
+ });
+}
+```
+
+### 4. Consume the hook in a component
+
+```tsx
+import { useWalletHoldings } from '@/hooks/useWalletHoldings';
+
+function HoldingsList({ address }: { address: string }) {
+ const { data: holdings, isLoading, error } = useWalletHoldings(address);
+
+ if (isLoading) return
+ {h.creatorId} — {h.quantity} keys at {h.priceStroops} stroops
+
+ ))}
+
+ );
+}
+```
+
+---
+
+## Key files at a glance
+
+| File | Purpose |
+| -------------------------------- | ------------------------------------------------- |
+| `src/services/api.service.ts` | `BaseApiService`, `ApiError`, `APIResponse` types |
+| `src/services/auth.service.ts` | Auth endpoints (login, register, profile) |
+| `src/services/course.service.ts` | Creator / course endpoints |
+| `src/lib/queryKeys.ts` | Centralised React Query key constants |
+| `src/providers/Web3Provider.tsx` | `QueryClientProvider` setup |
diff --git a/docs/environment-variables.md b/docs/environment-variables.md
new file mode 100644
index 00000000..fd805cac
--- /dev/null
+++ b/docs/environment-variables.md
@@ -0,0 +1,99 @@
+# Environment Variable Guide
+
+This guide explains how to add a new client environment variable safely and consistently in Access Layer Client.
+
+The client is built with Vite, so any value that must be available in browser code must use the `VITE_` prefix. Values without that prefix are not exposed to the client bundle.
+
+## Files involved
+
+| File | Purpose |
+| ------------------------ | ---------------------------------------------------------------------------------------------------------------- |
+| `.env.example` | Documents every supported variable and provides safe local defaults or blank optional placeholders. |
+| `src/utils/env.utils.ts` | Validates environment variables at startup with Zod and exports the typed `env` object used by application code. |
+| `.env` | Local developer overrides. This file should not be committed. |
+
+## Add a new variable
+
+1. Add the variable to `.env.example`.
+2. Add validation for the variable in `src/utils/env.utils.ts`.
+3. Pass the raw `import.meta.env` value into the `envSchema.parse(...)` call in `src/utils/env.utils.ts`.
+4. Import the validated `env` object in application code.
+5. Avoid reading `import.meta.env` directly from components, hooks, or service files.
+
+## Declaration pattern
+
+Add the new variable to `.env.example` near related settings. Use a short comment that explains what the value controls and whether it is required.
+
+```env
+# Feature flag for the creator discovery experiment. Use `true` to enable locally.
+VITE_ENABLE_CREATOR_DISCOVERY=false
+```
+
+Prefer safe development defaults when the app can run without secrets. Leave optional third-party keys blank if a contributor can work without them.
+
+## Runtime validation pattern
+
+All supported variables should be declared in `src/utils/env.utils.ts` so missing or malformed configuration is caught in one place.
+
+```ts
+const envSchema = z.object({
+ VITE_ENABLE_CREATOR_DISCOVERY: z.coerce.boolean().default(false),
+});
+
+export const env = envSchema.parse({
+ VITE_ENABLE_CREATOR_DISCOVERY: import.meta.env.VITE_ENABLE_CREATOR_DISCOVERY,
+});
+```
+
+Use the Zod type that matches how the app consumes the value:
+
+| Value type | Validation example |
+| --------------- | ----------------------------------------------- |
+| Required string | `z.string().min(1, "VITE_API_KEY is required")` |
+| Optional string | `z.string().optional()` |
+| Number | `z.coerce.number().default(84532)` |
+| Boolean flag | `z.coerce.boolean().default(false)` |
+
+If a value is required for the app to start, avoid a silent fallback. Use `.min(1, "... is required")` or another explicit validation rule so the startup error points to the missing variable.
+
+## Access pattern in application code
+
+Import `env` from the validation module and read the typed value from there:
+
+```ts
+import { env } from '@/utils/env.utils';
+
+if (env.VITE_ENABLE_CREATOR_DISCOVERY) {
+ // Render or enable the feature.
+}
+```
+
+This keeps validation, defaults, and type coercion centralized.
+
+## Anti-pattern: direct component access
+
+Do not import or read `import.meta.env` directly in components, hooks, services, or utilities outside the validation module.
+
+```tsx
+// Avoid this.
+const backendUrl = import.meta.env.VITE_BACKEND_URL;
+```
+
+Direct access bypasses schema validation, makes defaults inconsistent, and spreads environment knowledge across the app. Use `env` instead:
+
+```tsx
+import { env } from '@/utils/env.utils';
+
+const backendUrl = env.VITE_BACKEND_URL;
+```
+
+## Required vs optional checklist
+
+Use this checklist before opening a PR that adds a new variable:
+
+- The variable is listed in `.env.example`.
+- The variable has a clear comment describing its purpose.
+- Required values fail fast in `src/utils/env.utils.ts` with a useful error.
+- Optional values use `.optional()` or a safe `.default(...)`.
+- Application code reads from `env`, not `import.meta.env`.
+- The variable name starts with `VITE_` if browser code needs it.
diff --git a/docs/error-handling-in-hooks.md b/docs/error-handling-in-hooks.md
new file mode 100644
index 00000000..e5f813cf
--- /dev/null
+++ b/docs/error-handling-in-hooks.md
@@ -0,0 +1,373 @@
+# Error Handling in React Query Hooks
+
+This guide documents the standard pattern for handling API errors in React Query hooks across this codebase. Follow it when writing new `useQuery` or `useMutation` hooks so error behavior is consistent and predictable for users.
+
+---
+
+## How Errors Flow In
+
+All HTTP requests go through the service layer (`src/services/`), which extends `BaseApiService`. The `handleError` method on that base class normalises every failure into an `ApiError` before it reaches the hook:
+
+| Raw failure | What you receive |
+| ------------------------------------- | ------------------------------------------------------ |
+| HTTP response with an error status | `ApiError(message, httpStatus, responseBody)` |
+| Request sent but no response received | `ApiError('Network error - check your connection', 0)` |
+| Unexpected non-HTTP exception | `ApiError(error.message, 500)` |
+
+One important exception: **401 + `TOKEN_EXPIRED`** is handled transparently by the Axios interceptor in `BaseApiService`. The interceptor silently retries the original request after refreshing the access token. If the refresh also fails the user is redirected to `/login`; the hook never sees this error.
+
+---
+
+## The `ApiError` Shape
+
+```ts
+// src/services/api.service.ts
+class ApiError extends Error {
+ status: number; // HTTP status code; 0 means no network response
+ response?: {
+ success: false;
+ message: string;
+ code?: string; // machine-readable code from the API, e.g. "INSUFFICIENT_BALANCE"
+ errors?: Array<{
+ field?: string; // present on 422 validation failures
+ message: string;
+ }>;
+ };
+}
+```
+
+Always cast the error to `ApiError` before inspecting it:
+
+```ts
+import { ApiError } from '@/services/api.service';
+
+onError: error => {
+ const apiError = error as ApiError;
+ console.log(apiError.status); // 0, 400, 403, 422, 500 …
+ console.log(apiError.message); // human-readable message from the API
+ console.log(apiError.response?.errors); // field-level details on 422
+};
+```
+
+---
+
+## Distinguishing Error Types
+
+### Network errors (`status === 0`)
+
+No response was received — the user is offline, the server is unreachable, or a timeout occurred. The user cannot fix the request payload; they need to retry later.
+
+```ts
+if (apiError.status === 0) {
+ showToast.error('Network error. Check your connection and try again.');
+ return;
+}
+```
+
+### 4xx — Client errors
+
+The request was received but rejected because of something the client sent. The message from the API is usually safe to show to the user.
+
+| Status | Cause | Typical UI response |
+| ------ | ------------------------ | ---------------------------------------------------- |
+| 400 | Malformed request | Toast with `apiError.message` |
+| 401 | Session expired | Auto-handled by the interceptor |
+| 403 | Insufficient permissions | Inline error or redirect |
+| 404 | Resource not found | Inline error state |
+| 422 | Validation failure | Inline field errors from `apiError.response?.errors` |
+| 429 | Rate limited | Toast with retry suggestion |
+
+### 5xx — Server errors
+
+The API itself failed. The user cannot fix the payload; they can only retry after the server recovers. Avoid showing raw server messages — use a generic fallback instead.
+
+```ts
+if (apiError.status >= 500) {
+ showToast.error('The server ran into a problem. Please try again shortly.');
+ return;
+}
+```
+
+---
+
+## Deciding: Toast vs. Inline Error vs. Error Boundary
+
+### Use a toast when
+
+- The failure came from a **user-initiated action** (mutation): buying a key, submitting a form, enrolling in a course.
+- The error **does not block the current view** — the page can still render usefully.
+- The fix is to retry or change input: one line of feedback is enough.
+
+```ts
+onError: error => {
+ const apiError = error as ApiError;
+ showToast.error(
+ apiError.status >= 500
+ ? 'Something went wrong. Try again.'
+ : apiError.message
+ );
+};
+```
+
+### Use an inline error state when
+
+- The error **blocks the primary purpose of the screen** — for example, the creator list failed to load so the page is empty.
+- The error contains **field-level detail** (422) that needs to map to specific form inputs.
+- The user needs to take **corrective action** (fix a field, switch networks) before retrying makes sense.
+
+```tsx
+const { data, isError, error } = useCreatorKeys(creatorId);
+
+if (isError) {
+ const apiError = error as ApiError;
+ return (
+
+ {apiError.status >= 500
+ ? 'Unable to load data. Please try again later.'
+ : apiError.message}
+
+ );
+}
+```
+
+### Use `SectionErrorBoundary` when
+
+- A **component throws during render**, not from an API call.
+- You want to **isolate a section** so one broken widget does not crash the whole page.
+- React Query's `throwOnError` option is enabled on a query.
+
+```tsx
+import SectionErrorBoundary from '@/components/common/SectionErrorBoundary';
+
+
+
+;
+```
+
+`SectionErrorBoundary` renders a retry button that resets its own error state. Use it as a safety net around sections that fetch and render data together.
+
+---
+
+## `useQuery` Pattern
+
+React Query v5 removed the `onError` callback from `useQuery`. Errors surface through `isError` and `error` in the component. Keep the hook thin and handle the error at the call site:
+
+```ts
+// src/hooks/useCreatorProfile.ts
+import { useQuery } from '@tanstack/react-query';
+import { creatorService } from '@/services/creator.service';
+
+export function useCreatorProfile(creatorId: string) {
+ return useQuery({
+ queryKey: ['creator-profile', creatorId],
+ queryFn: () => creatorService.getProfile(creatorId),
+ staleTime: 30_000,
+ });
+}
+```
+
+```tsx
+// In the component
+import { ApiError } from '@/services/api.service';
+import { useCreatorProfile } from '@/hooks/useCreatorProfile';
+
+function CreatorProfileSection({ creatorId }: { creatorId: string }) {
+ const { data, isLoading, isError, error } = useCreatorProfile(creatorId);
+
+ if (isLoading) return ;
+
+ if (isError) {
+ const apiError = error as ApiError;
+ return (
+
+ {apiError.status >= 500
+ ? 'Unable to load this profile right now.'
+ : apiError.message}
+
+ );
+ }
+
+ return ;
+}
+```
+
+---
+
+## `useMutation` Pattern
+
+`useMutation` still accepts `onError` and `onSuccess` callbacks. Use them for toasts and cache invalidation:
+
+```ts
+// src/hooks/useEnrollInCourse.ts
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { courseService } from '@/services/course.service';
+import { ApiError } from '@/services/api.service';
+import showToast from '@/utils/toast.util';
+
+export function useEnrollInCourse() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (courseId: string) => courseService.enrollInCourse(courseId),
+ onError: error => {
+ const apiError = error as ApiError;
+
+ if (apiError.status === 0) {
+ showToast.error(
+ 'Network error. Check your connection and try again.'
+ );
+ return;
+ }
+
+ if (apiError.status >= 500) {
+ showToast.error(
+ 'Something went wrong on our end. Please try again.'
+ );
+ return;
+ }
+
+ // 4xx: the API message is safe and actionable
+ showToast.error(apiError.message);
+ },
+ onSuccess: (_, courseId) => {
+ queryClient.invalidateQueries({ queryKey: ['enrolled-courses'] });
+ queryClient.invalidateQueries({ queryKey: ['course', courseId] });
+ showToast.success('Enrolled successfully!');
+ },
+ });
+}
+```
+
+---
+
+## Worked Example: Handling Both Error Types
+
+The following hook wraps a write operation (buying a creator key) and shows how to handle network errors, 4xx validation failures, and 5xx server errors in a single consistent flow.
+
+```ts
+// src/hooks/useCreatorKeys.ts
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { ApiError } from '@/services/api.service';
+import showToast from '@/utils/toast.util';
+import { creatorKeysService } from '@/services/creatorKeys.service';
+
+// --- Read ---
+export function useCreatorKeys(creatorId: string) {
+ return useQuery({
+ queryKey: ['creator-keys', creatorId],
+ queryFn: () => creatorKeysService.getKeys(creatorId),
+ staleTime: 30_000,
+ });
+}
+
+// --- Write ---
+export function useBuyCreatorKey() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({
+ creatorId,
+ amount,
+ }: {
+ creatorId: string;
+ amount: number;
+ }) => creatorKeysService.buyKey(creatorId, amount),
+
+ onError: error => {
+ const apiError = error as ApiError;
+
+ // No response received — user is likely offline
+ if (apiError.status === 0) {
+ showToast.error(
+ 'Network error. Check your connection and try again.'
+ );
+ return;
+ }
+
+ // Server-side failure — not actionable by the user
+ if (apiError.status >= 500) {
+ showToast.error(
+ 'The server ran into a problem. Please try again shortly.'
+ );
+ return;
+ }
+
+ // 422 Validation — show the first field error if available
+ if (apiError.status === 422 && apiError.response?.errors?.length) {
+ showToast.error(apiError.response.errors[0].message);
+ return;
+ }
+
+ // All other 4xx — the API message is safe to surface
+ showToast.error(apiError.message);
+ },
+
+ onSuccess: (_, { creatorId }) => {
+ // Invalidate relevant queries so the UI reflects the purchase
+ queryClient.invalidateQueries({
+ queryKey: ['creator-keys', creatorId],
+ });
+ queryClient.invalidateQueries({ queryKey: ['user-holdings'] });
+ showToast.success('Key purchased successfully!');
+ },
+ });
+}
+```
+
+Usage in a component:
+
+```tsx
+import { ApiError } from '@/services/api.service';
+import { useCreatorKeys, useBuyCreatorKey } from '@/hooks/useCreatorKeys';
+import SectionErrorBoundary from '@/components/common/SectionErrorBoundary';
+
+function CreatorKeysSection({ creatorId }: { creatorId: string }) {
+ const { data: keys, isLoading, isError, error } = useCreatorKeys(creatorId);
+ const { mutate: buyKey, isPending } = useBuyCreatorKey();
+
+ if (isLoading) return ;
+
+ if (isError) {
+ const apiError = error as ApiError;
+ return (
+
+
+ {apiError.status >= 500
+ ? 'Unable to load keys right now. Please try again later.'
+ : apiError.message}
+
+
+ );
+ }
+
+ return (
+ // SectionErrorBoundary catches any render-time throws inside KeysList
+
+ buyKey({ creatorId, amount })}
+ isBuying={isPending}
+ />
+
+ );
+}
+```
+
+---
+
+## Quick Reference
+
+| Condition | Check | UI response |
+| ------------------- | ------------------------------- | ------------------------------------------------------------ |
+| No network response | `apiError.status === 0` | Toast: "Network error. Check your connection." |
+| Server error | `apiError.status >= 500` | Toast: generic "something went wrong" message |
+| Validation failure | `apiError.status === 422` | Toast or inline: first item from `apiError.response?.errors` |
+| Other 4xx | `apiError.status >= 400` | Toast or inline: `apiError.message` (safe from the API) |
+| Read query fails | `isError === true` in component | Inline error state replacing the content area |
+| Render throws | Component boundary | Wrap with `` |
diff --git a/docs/marketing-page-copy.md b/docs/marketing-page-copy.md
new file mode 100644
index 00000000..81079db1
--- /dev/null
+++ b/docs/marketing-page-copy.md
@@ -0,0 +1,91 @@
+# Editing Marketing Page Copy
+
+This guide is for non-technical contributors who want to suggest changes to the
+marketing page copy. You can edit the text directly on GitHub and open a pull
+request — no local development environment is required.
+
+## Where the copy lives
+
+All marketing page copy is in a single file:
+
+**`src/pages/MarketingPage.tsx`**
+
+The page is a single React component. Each visible section is a block of JSX
+with inline text. Use the table below to find the section you want to change.
+
+| Visible section on the page | Location in `MarketingPage.tsx` | What to look for |
+| ------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------ |
+| Page title ("Access Layer") | Hero / Title | `
` with the `Access Layer` heading |
+| Intro paragraph under the title | Intro | First `
` after the title |
+| **The idea** | `{/* The idea */}` section | Eyebrow text `The idea` and the two body paragraphs below it |
+| **How it works** | `{/* How it works */}` section | Eyebrow text `How it works` and the two body paragraphs below it |
+| **What makes it different** | `{/* What makes it different */}` section | Eyebrow text `What makes it different` and the body paragraph below it |
+| **Built on Stellar** | `{/* Built on Stellar */}` section | Eyebrow text `Built on Stellar` and the two body paragraphs below it |
+| **Join the community** | `{/* Community */}` section | Eyebrow text `Join the community`, the subtitle, and the GitHub/Telegram links |
+| Footer | `{/* Footer */}` section | Logo label and the "Built on Stellar" tagline |
+
+Section eyebrows use this pattern — a short uppercase label in blue:
+
+```tsx
+