Skip to main content
On this page

Implementation blueprint for the Progressive Revelation system. ProgRev progressively reveals content and features as users demonstrate readiness, reducing cognitive overload while guiding them through the application.

Foundation: See ../foundation/progressive-revelation.md for philosophy and anti-goals.


Architecture Overview

┌─────────────────────────────────────────────────────────────────────────────┐
│                           REQUEST FLOW                                       │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  1. hooks.server.ts ──► Load guest_id cookie, verify guest exists           │
│          │                                                                   │
│          ▼                                                                   │
│  2. hooks.server.ts ──► Load ProgRev state from DB → event.locals.progrev   │
│          │                                                                   │
│          ▼                                                                   │
│  3. +layout.server.ts ──► Pass progrev to page data                         │
│          │                                                                   │
│          ▼                                                                   │
│  4. +layout.svelte ──► Sync to localStorage cache, provide context          │
│          │                                                                   │
│          ▼                                                                   │
│  5. Components ──► Consume via useProgRev(), gate with <ProgRevGate>        │
│          │                                                                   │
│          ▼                                                                   │
│  6. Form Actions ──► recordAction() → DB update → invalidateAll()           │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Key Principles:

  • Server-authoritative: PostgreSQL is canonical; client cannot lie about progress
  • Write-through cache: localStorage caches server state; never written by client actions
  • Guest-first: Create guest on first meaningful action, not page load
  • Atomic merge: Guest → user data transfer in single transaction

Research Validation

Research validates the proposed architecture:

Assumption Validation Source
Progressive disclosure reduces cognitive load Verified - NN/g 1995, reconfirmed 2025 Nielsen Norman Group
Server-authoritative required for feature gating "Client-side only opens you to abuse cases" Statsig Documentation
Session IDs must regenerate on privilege change OWASP standard (anonymous → authenticated) OWASP Cheat Sheet
Forced tutorials underperform progressive 72% abandon apps with too many onboarding steps UserGuiding 2024
Average onboarding completion: ~19% Median: 10.1% across 188 companies Userpilot 2024
Gamification increases completion by 50% Progress indicators, badges, checklists Intercom Research

Anti-patterns to avoid (verified):

  • Dark patterns found on >10% of popular sites (Princeton/UChicago 2019)
  • Epic Games paid $245M settlement for deceptive payment patterns
  • Function-oriented onboarding "feels like an information dump"

Database Schema

Tables Overview

Table Purpose Ownership
guest Anonymous visitor identity Top-level
progrev_progress FTUX phases, actions, milestones Guest XOR User
onboarding_progress Post-signup journey User only
achievement_definition Achievement catalog Reference
user_achievement Earned achievements User only
bookmark Saved content Guest XOR User
seen_content Viewed content for muting Guest XOR User

Schema Definition

// src/lib/server/db/schema/progrev.ts
import {
  pgTable, text, timestamp, integer, boolean, pgEnum,
  index, unique, check
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { user } from './_better-auth';

// ============================================================================
// ENUMS
// ============================================================================

export const ftuxPhaseEnum = pgEnum('ftux_phase', [
  'landing',      // Phase 1: Initial view
  'exploration',  // Phase 2: After first CTA click
  'commitment'    // Phase 3: Ready for signup
]);

export const achievementTierEnum = pgEnum('achievement_tier', [
  'bronze', 'silver', 'gold', 'platinum'
]);

// ============================================================================
// GUEST (Anonymous Visitor Identity)
// ============================================================================

export const guest = pgTable('guest', {
  id: text('id').primaryKey(),  // gst_xxxxxxxxxxxx
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow(),

  // Merge tracking (audit trail)
  mergedToUserId: text('merged_to_user_id').references(() => user.id, { onDelete: 'set null' }),
  mergedAt: timestamp('merged_at', { withTimezone: true }),
}, (table) => [
  index('guest_last_seen_at_idx').on(table.lastSeenAt),
  index('guest_merged_to_user_id_idx').on(table.mergedToUserId),
]);

// ============================================================================
// PROGREV PROGRESS (FTUX State - Guest or User)
// ============================================================================

export const progrevProgress = pgTable('progrev_progress', {
  id: text('id').primaryKey(),  // prg_xxxxxxxxxxxx

  // Dual ownership: exactly one must be non-null
  guestId: text('guest_id').references(() => guest.id, { onDelete: 'cascade' }),
  userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }),

  // FTUX state
  ftuxPhase: ftuxPhaseEnum('ftux_phase').notNull().default('landing'),

  // TEXT[] for actions - simple, fast, no JSON parsing
  completedActions: text('completed_actions').array().notNull().default(sql`'{}'::text[]`),
  milestones: text('milestones').array().notNull().default(sql`'{}'::text[]`),

  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
  index('progrev_progress_guest_id_idx').on(table.guestId),
  index('progrev_progress_user_id_idx').on(table.userId),

  // Exactly one owner
  check('progrev_progress_owner_check', sql`
    (guest_id IS NOT NULL AND user_id IS NULL) OR
    (guest_id IS NULL AND user_id IS NOT NULL)
  `),

  // One progress record per owner
  unique('progrev_progress_guest_unique').on(table.guestId),
  unique('progrev_progress_user_unique').on(table.userId),
]);

// ============================================================================
// ONBOARDING PROGRESS (Post-Signup - User Only)
// ============================================================================

export const onboardingProgress = pgTable('onboarding_progress', {
  userId: text('user_id')
    .primaryKey()
    .references(() => user.id, { onDelete: 'cascade' }),

  currentStage: integer('current_stage').notNull().default(1),
  completedStages: integer('completed_stages').array().notNull().default(sql`'{}'::int[]`),
  skipped: boolean('skipped').notNull().default(false),

  startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
  completedAt: timestamp('completed_at', { withTimezone: true }),
});

// ============================================================================
// ACHIEVEMENTS
// ============================================================================

export const achievementDefinition = pgTable('achievement_definition', {
  id: text('id').primaryKey(),
  key: text('key').notNull().unique(),  // 'PIONEER', 'NAVIGATOR', 'TRAILBLAZER'
  name: text('name').notNull(),
  description: text('description').notNull(),
  tier: achievementTierEnum('tier').notNull().default('bronze'),
  iconUrl: text('icon_url'),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
  index('achievement_definition_key_idx').on(table.key),
]);

export const userAchievement = pgTable('user_achievement', {
  id: text('id').primaryKey(),
  userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
  achievementId: text('achievement_id').notNull()
    .references(() => achievementDefinition.id, { onDelete: 'cascade' }),
  earnedAt: timestamp('earned_at', { withTimezone: true }).notNull().defaultNow(),
  context: text('context'),  // JSON string for metadata
}, (table) => [
  index('user_achievement_user_id_idx').on(table.userId),
  unique('user_achievement_user_achievement_unique').on(table.userId, table.achievementId),
]);

// ============================================================================
// CONTENT TRACKING (Guest or User)
// ============================================================================

export const bookmark = pgTable('bookmark', {
  id: text('id').primaryKey(),
  guestId: text('guest_id').references(() => guest.id, { onDelete: 'cascade' }),
  userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }),
  contentKey: text('content_key').notNull(),
  label: text('label'),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
  index('bookmark_guest_id_idx').on(table.guestId),
  index('bookmark_user_id_idx').on(table.userId),
  check('bookmark_owner_check', sql`
    (guest_id IS NOT NULL AND user_id IS NULL) OR
    (guest_id IS NULL AND user_id IS NOT NULL)
  `),
  unique('bookmark_guest_content_unique').on(table.guestId, table.contentKey),
  unique('bookmark_user_content_unique').on(table.userId, table.contentKey),
]);

export const seenContent = pgTable('seen_content', {
  id: text('id').primaryKey(),
  guestId: text('guest_id').references(() => guest.id, { onDelete: 'cascade' }),
  userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }),
  contentKey: text('content_key').notNull(),
  seenAt: timestamp('seen_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
  index('seen_content_guest_id_idx').on(table.guestId),
  index('seen_content_user_id_idx').on(table.userId),
  check('seen_content_owner_check', sql`
    (guest_id IS NOT NULL AND user_id IS NULL) OR
    (guest_id IS NULL AND user_id IS NOT NULL)
  `),
  unique('seen_content_guest_content_unique').on(table.guestId, table.contentKey),
  unique('seen_content_user_content_unique').on(table.userId, table.contentKey),
]);

ID Generation

// src/lib/server/db/id.ts
import { nanoid } from 'nanoid';

export const createId = {
  guest: () => `gst_${nanoid(12)}`,
  progrevProgress: () => `prg_${nanoid(12)}`,
  achievement: () => `ach_${nanoid(8)}`,
  userAchievement: () => `uac_${nanoid(12)}`,
  bookmark: () => `bmk_${nanoid(12)}`,
  seenContent: () => `scn_${nanoid(12)}`,
};

SvelteKit Implementation

Type Definitions

// src/app.d.ts
declare global {
  namespace App {
    interface Locals {
      user: User | null;
      session: Session | null;
      guest: Guest | null;
      progrev: ProgRevState;
    }

    interface PageData {
      progrev: ProgRevState;
    }
  }
}

export interface Guest {
  id: string;
  createdAt: Date;
  lastSeenAt: Date;
  mergedToUserId: string | null;
}

export type FtuxPhase = 'landing' | 'exploration' | 'commitment';

export interface ProgRevState {
  identity: 'anonymous' | 'guest' | 'authenticated';
  guestId?: string;
  userId?: string;
  ftuxPhase: FtuxPhase;
  completedActions: string[];
  milestones: string[];
  onboardingStage: number | null;
  onboardingSkipped: boolean;
  achievements: string[];
}

export {};

Server Hooks

// src/hooks.server.ts
import { sequence } from '@sveltejs/kit/hooks';
import type { Handle } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { guest, progrevProgress } from '$lib/server/db/schema/progrev';
import { eq, and, isNull } from 'drizzle-orm';

const GUEST_COOKIE = 'guest_id';
const GUEST_MAX_AGE = 60 * 60 * 24 * 90; // 90 days

/**
 * Load guest identity from cookie.
 * Does NOT create guest - that happens on first meaningful action.
 */
const loadGuestIdentity: Handle = async ({ event, resolve }) => {
  const guestId = event.cookies.get(GUEST_COOKIE);

  if (guestId) {
    const [guestRecord] = await db
      .select()
      .from(guest)
      .where(and(
        eq(guest.id, guestId),
        isNull(guest.mergedToUserId)
      ))
      .limit(1);

    if (guestRecord) {
      event.locals.guest = guestRecord;

      // Touch lastSeenAt (fire-and-forget)
      db.update(guest)
        .set({ lastSeenAt: new Date() })
        .where(eq(guest.id, guestId))
        .execute()
        .catch(console.error);
    } else {
      // Guest merged or deleted
      event.cookies.delete(GUEST_COOKIE, { path: '/' });
    }
  }

  return resolve(event);
};

/**
 * Load ProgRev state from database.
 */
const loadProgRevState: Handle = async ({ event, resolve }) => {
  const user = event.locals.user;
  const guestRecord = event.locals.guest;

  // Default state for anonymous users
  let state: ProgRevState = {
    identity: 'anonymous',
    ftuxPhase: 'landing',
    completedActions: [],
    milestones: [],
    onboardingStage: null,
    onboardingSkipped: false,
    achievements: []
  };

  try {
    if (user) {
      // Authenticated user
      const [progress] = await db
        .select()
        .from(progrevProgress)
        .where(eq(progrevProgress.userId, user.id))
        .limit(1);

      state = {
        identity: 'authenticated',
        userId: user.id,
        ftuxPhase: progress?.ftuxPhase ?? 'commitment', // Auth users skip FTUX
        completedActions: progress?.completedActions ?? [],
        milestones: progress?.milestones ?? [],
        onboardingStage: progress?.onboardingStage ?? 1,
        onboardingSkipped: false,
        achievements: [] // Load separately if needed
      };
    } else if (guestRecord) {
      // Guest user
      const [progress] = await db
        .select()
        .from(progrevProgress)
        .where(eq(progrevProgress.guestId, guestRecord.id))
        .limit(1);

      if (progress) {
        state = {
          identity: 'guest',
          guestId: guestRecord.id,
          ftuxPhase: progress.ftuxPhase,
          completedActions: progress.completedActions,
          milestones: progress.milestones,
          onboardingStage: null,
          onboardingSkipped: false,
          achievements: []
        };
      }
    }
  } catch (err) {
    console.error('Failed to load ProgRev state:', err);
  }

  event.locals.progrev = state;
  return resolve(event);
};

export const handle = sequence(
  // betterAuthHandle, // Better Auth (populates event.locals.user)
  loadGuestIdentity,
  loadProgRevState,
  // rateLimitHandle, // Rate limiting
);

Root Layout

// src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types';

export const load: LayoutServerLoad = async ({ locals }) => {
  return {
    user: locals.user,
    session: locals.session,
    progrev: locals.progrev
  };
};
<!-- src/routes/+layout.svelte -->
<script lang="ts">
  import { setContext, onMount } from 'svelte';
  import { page } from '$app/state';
  import type { ProgRevState } from '$lib/types';

  let { children } = $props();

  // Cache management
  let cachedProgRev = $state<ProgRevState | null>(null);

  // Write-through: server state → localStorage
  $effect(() => {
    const serverProgRev = page.data.progrev;
    if (serverProgRev) {
      localStorage.setItem('progrev_cache', JSON.stringify(serverProgRev));
      cachedProgRev = serverProgRev;
    }
  });

  // Read cache on mount (prevent flash)
  onMount(() => {
    const cached = localStorage.getItem('progrev_cache');
    if (cached) {
      try {
        cachedProgRev = JSON.parse(cached);
      } catch {}
    }
  });

  // Server data wins when available
  const progrev = $derived(page.data.progrev ?? cachedProgRev);

  // Provide context
  setContext('progrev', {
    get state() { return progrev; }
  });
</script>

{@render children()}

Action Recording

// src/lib/server/progrev/actions.ts
import { db, createId } from '$lib/server/db';
import { guest, progrevProgress } from '$lib/server/db/schema/progrev';
import { eq, and } from 'drizzle-orm';
import type { RequestEvent } from '@sveltejs/kit';
import type { FtuxPhase } from '$lib/types';

const GUEST_COOKIE = 'guest_id';
const GUEST_MAX_AGE = 60 * 60 * 24 * 90;

/**
 * Creates a guest on first meaningful action.
 */
export async function createGuest(event: RequestEvent): Promise<string> {
  const id = createId.guest();

  await db.insert(guest).values({
    id,
    createdAt: new Date(),
    lastSeenAt: new Date()
  });

  await db.insert(progrevProgress).values({
    id: createId.progrevProgress(),
    guestId: id,
    ftuxPhase: 'landing',
    completedActions: [],
    milestones: []
  });

  event.cookies.set(GUEST_COOKIE, id, {
    path: '/',
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: GUEST_MAX_AGE
  });

  return id;
}

/**
 * Records a meaningful action.
 * Creates guest if anonymous.
 */
export async function recordAction(
  event: RequestEvent,
  actionId: string,
  options: {
    advancePhase?: FtuxPhase;
    addMilestones?: string[];
  } = {}
): Promise<void> {
  const user = event.locals.user;
  let guestRecord = event.locals.guest;

  // Create guest for anonymous users
  if (!user && !guestRecord) {
    const guestId = await createGuest(event);
    guestRecord = { id: guestId, createdAt: new Date(), lastSeenAt: new Date(), mergedToUserId: null };
    event.locals.guest = guestRecord;
  }

  const ownerId = user?.id ?? guestRecord!.id;
  const ownerField = user ? 'userId' : 'guestId';

  // Get existing progress
  const [existing] = await db
    .select()
    .from(progrevProgress)
    .where(eq(progrevProgress[ownerField], ownerId))
    .limit(1);

  if (!existing) {
    // Create progress record
    await db.insert(progrevProgress).values({
      id: createId.progrevProgress(),
      [ownerField]: ownerId,
      ftuxPhase: options.advancePhase ?? 'landing',
      completedActions: [actionId],
      milestones: options.addMilestones ?? []
    });
  } else {
    // Update existing
    const updates: Partial<typeof progrevProgress.$inferInsert> = {
      completedActions: [...new Set([...existing.completedActions, actionId])],
      updatedAt: new Date()
    };

    if (options.advancePhase) {
      updates.ftuxPhase = options.advancePhase;
    }

    if (options.addMilestones) {
      updates.milestones = [...new Set([...existing.milestones, ...options.addMilestones])];
    }

    await db
      .update(progrevProgress)
      .set(updates)
      .where(eq(progrevProgress[ownerField], ownerId));
  }
}

Guest-to-User Merge

// src/lib/server/progrev/merge.ts
import { db, createId } from '$lib/server/db';
import {
  guest, progrevProgress, bookmark, seenContent,
  userAchievement, achievementDefinition, onboardingProgress
} from '$lib/server/db/schema/progrev';
import { eq, and, isNull } from 'drizzle-orm';

interface MergeResult {
  success: boolean;
  achievementsGranted: string[];
  bookmarksMerged: number;
  seenContentMerged: number;
}

/**
 * Merges guest data into user account.
 * Called after successful signup.
 *
 * Conflict resolution:
 * - FTUX phase: take higher (further along)
 * - Actions/milestones: union of sets
 * - Bookmarks: union (skip duplicates)
 */
export async function mergeGuestToUser(
  guestId: string,
  userId: string
): Promise<MergeResult> {
  return db.transaction(async (tx) => {
    const result: MergeResult = {
      success: false,
      achievementsGranted: [],
      bookmarksMerged: 0,
      seenContentMerged: 0,
    };

    // 1. Verify guest exists and not merged
    const [guestRecord] = await tx
      .select()
      .from(guest)
      .where(and(eq(guest.id, guestId), isNull(guest.mergedToUserId)))
      .limit(1);

    if (!guestRecord) return result;

    // 2. Get guest progress
    const [guestProgress] = await tx
      .select()
      .from(progrevProgress)
      .where(eq(progrevProgress.guestId, guestId))
      .limit(1);

    // 3. Get or create user progress
    const [userProgress] = await tx
      .select()
      .from(progrevProgress)
      .where(eq(progrevProgress.userId, userId))
      .limit(1);

    if (!userProgress && guestProgress) {
      // Transfer guest progress to user
      await tx.update(progrevProgress)
        .set({
          guestId: null,
          userId: userId,
          ftuxPhase: 'commitment', // Auth users complete FTUX
          updatedAt: new Date(),
        })
        .where(eq(progrevProgress.guestId, guestId));
    } else if (userProgress && guestProgress) {
      // Merge progress (take higher phase, union of actions)
      const phaseOrder = { landing: 1, exploration: 2, commitment: 3 };
      const higherPhase = phaseOrder[guestProgress.ftuxPhase] > phaseOrder[userProgress.ftuxPhase]
        ? guestProgress.ftuxPhase
        : 'commitment';

      await tx.update(progrevProgress)
        .set({
          ftuxPhase: higherPhase,
          completedActions: [...new Set([
            ...userProgress.completedActions,
            ...guestProgress.completedActions
          ])],
          milestones: [...new Set([
            ...userProgress.milestones,
            ...guestProgress.milestones
          ])],
          updatedAt: new Date(),
        })
        .where(eq(progrevProgress.userId, userId));

      // Delete guest progress
      await tx.delete(progrevProgress)
        .where(eq(progrevProgress.guestId, guestId));
    }

    // 4. Transfer bookmarks (skip duplicates)
    const guestBookmarks = await tx
      .select()
      .from(bookmark)
      .where(eq(bookmark.guestId, guestId));

    for (const bm of guestBookmarks) {
      const [exists] = await tx
        .select()
        .from(bookmark)
        .where(and(
          eq(bookmark.userId, userId),
          eq(bookmark.contentKey, bm.contentKey)
        ))
        .limit(1);

      if (!exists) {
        await tx.update(bookmark)
          .set({ guestId: null, userId: userId })
          .where(eq(bookmark.id, bm.id));
        result.bookmarksMerged++;
      } else {
        await tx.delete(bookmark).where(eq(bookmark.id, bm.id));
      }
    }

    // 5. Transfer seen content (skip duplicates)
    const guestSeen = await tx
      .select()
      .from(seenContent)
      .where(eq(seenContent.guestId, guestId));

    for (const sc of guestSeen) {
      const [exists] = await tx
        .select()
        .from(seenContent)
        .where(and(
          eq(seenContent.userId, userId),
          eq(seenContent.contentKey, sc.contentKey)
        ))
        .limit(1);

      if (!exists) {
        await tx.update(seenContent)
          .set({ guestId: null, userId: userId })
          .where(eq(seenContent.id, sc.id));
        result.seenContentMerged++;
      } else {
        await tx.delete(seenContent).where(eq(seenContent.id, sc.id));
      }
    }

    // 6. Mark guest as merged (keep for audit)
    await tx.update(guest)
      .set({ mergedToUserId: userId, mergedAt: new Date() })
      .where(eq(guest.id, guestId));

    // 7. Grant PIONEER achievement
    const [pioneer] = await tx
      .select()
      .from(achievementDefinition)
      .where(eq(achievementDefinition.key, 'PIONEER'))
      .limit(1);

    if (pioneer) {
      await tx.insert(userAchievement)
        .values({
          id: createId.userAchievement(),
          userId,
          achievementId: pioneer.id,
          context: JSON.stringify({ trigger: 'signup', fromGuest: guestId }),
        })
        .onConflictDoNothing();
      result.achievementsGranted.push('PIONEER');
    }

    // 8. Create onboarding progress
    await tx.insert(onboardingProgress)
      .values({ userId })
      .onConflictDoNothing();

    result.success = true;
    return result;
  });
}

UI Components

ProgRevGate

Conditionally renders content based on ProgRev state.

<!-- src/lib/progrev/ProgRevGate.svelte -->
<script lang="ts">
  import { getContext } from 'svelte';
  import type { Snippet } from 'svelte';
  import type { FtuxPhase, ProgRevState } from '$lib/types';

  interface Props {
    /** Required FTUX phase */
    phase?: FtuxPhase;

    /** Required identity level */
    identity?: 'guest' | 'authenticated';

    /** Required action completion */
    requiresAction?: string;

    /** Content when unlocked */
    children: Snippet;

    /** Content when locked */
    fallback?: Snippet;
  }

  let { phase, identity, requiresAction, children, fallback }: Props = $props();

  const progrev = getContext<{ state: ProgRevState }>('progrev');

  const phaseOrder = { landing: 1, exploration: 2, commitment: 3 };

  const isUnlocked = $derived(() => {
    const state = progrev.state;
    if (!state) return false;

    // Check identity
    if (identity === 'authenticated' && state.identity !== 'authenticated') return false;
    if (identity === 'guest' && state.identity === 'anonymous') return false;

    // Check phase
    if (phase && phaseOrder[state.ftuxPhase] < phaseOrder[phase]) return false;

    // Check action
    if (requiresAction && !state.completedActions.includes(requiresAction)) return false;

    return true;
  });
</script>

{#if isUnlocked()}
  {@render children()}
{:else if fallback}
  {@render fallback()}
{/if}

Usage:

<ProgRevGate phase="exploration">
  <ContentSection />

  {#snippet fallback()}
    <LockedPreview onclick={handleCTA} />
  {/snippet}
</ProgRevGate>

ProgressIndicator

Ambient progress display for sidebar.

<!-- src/lib/progrev/ProgressIndicator.svelte -->
<script lang="ts">
  import { getContext } from 'svelte';
  import type { ProgRevState } from '$lib/types';

  interface Props {
    density?: 'compact' | 'default' | 'detailed';
    orientation?: 'vertical' | 'horizontal';
  }

  let { density = 'default', orientation = 'vertical' }: Props = $props();

  const progrev = getContext<{ state: ProgRevState }>('progrev');

  const stages = [
    { id: 'landing', label: 'Welcome', phase: 'landing' },
    { id: 'exploration', label: 'Explore', phase: 'exploration' },
    { id: 'commitment', label: 'Ready', phase: 'commitment' }
  ] as const;

  const phaseOrder = { landing: 1, exploration: 2, commitment: 3 };

  function getStageState(phase: string) {
    const current = progrev.state?.ftuxPhase ?? 'landing';
    const currentNum = phaseOrder[current];
    const stageNum = phaseOrder[phase];

    if (stageNum < currentNum) return 'completed';
    if (stageNum === currentNum) return 'current';
    return 'upcoming';
  }
</script>

<nav class="progress-indicator {orientation}" aria-label="Progress">
  <ol class="stage-list">
    {#each stages as stage}
      {@const state = getStageState(stage.phase)}
      <li class="stage {state}">
        <div class="stage-dot">
          {#if state === 'completed'}
            <span class="i-mdi-check"></span>
          {/if}
        </div>
        {#if density !== 'compact'}
          <span class="stage-label">{stage.label}</span>
        {/if}
      </li>
    {/each}
  </ol>
</nav>

<style>
  .progress-indicator { --completed: hsl(142 71% 45%); --current: hsl(221 83% 53%); }
  .stage-list { list-style: none; padding: 0; margin: 0; display: flex; }
  .vertical .stage-list { flex-direction: column; gap: 1.5rem; }
  .horizontal .stage-list { justify-content: space-around; }

  .stage-dot {
    w-8 h-8 rounded-full flex items-center justify-center;
    transition: all 200ms;
  }
  .completed .stage-dot { background: var(--completed); color: white; }
  .current .stage-dot { background: var(--current); color: white; box-shadow: 0 0 0 4px hsl(221 83% 53% / 0.15); }
  .upcoming .stage-dot { background: hsl(0 0% 80%); }

  .completed .stage-label { opacity: 0.6; }
  .upcoming .stage-label { opacity: 0.4; }
</style>

AchievementToast

Celebratory notification for milestones.

<!-- src/lib/progrev/AchievementToast.svelte -->
<script lang="ts">
  interface Props {
    achievement: 'PIONEER' | 'NAVIGATOR' | 'TRAILBLAZER';
    duration?: number;
    onDismiss?: () => void;
  }

  let { achievement, duration = 5000, onDismiss }: Props = $props();

  let visible = $state(true);

  const config = {
    PIONEER: { icon: 'i-mdi-rocket-launch', title: 'Pioneer', desc: "You're in. Welcome to the frontier." },
    NAVIGATOR: { icon: 'i-mdi-compass', title: 'Navigator', desc: 'You charted the course.' },
    TRAILBLAZER: { icon: 'i-mdi-terrain', title: 'Trailblazer', desc: 'You forge your own path.' }
  };

  const { icon, title, desc } = config[achievement];

  $effect(() => {
    if (duration > 0) {
      const timeout = setTimeout(() => {
        visible = false;
        onDismiss?.();
      }, duration);
      return () => clearTimeout(timeout);
    }
  });
</script>

{#if visible}
  <div class="achievement-toast" role="status" aria-live="polite">
    <span class={icon}></span>
    <div>
      <strong>{title}</strong>
      <p>{desc}</p>
    </div>
    <button onclick={() => { visible = false; onDismiss?.(); }} aria-label="Dismiss">
      <span class="i-mdi-close"></span>
    </button>
  </div>
{/if}

<style>
  .achievement-toast {
    position: fixed; top: 1rem; right: 1rem; z-index: 1000;
    display: flex; align-items: center; gap: 1rem;
    background: white; border: 1px solid hsl(0 0% 90%);
    border-radius: 0.5rem; padding: 1rem; max-width: 24rem;
    box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
    animation: slide-in 300ms ease-out;
  }
  @keyframes slide-in {
    from { transform: translateX(calc(100% + 1rem)); opacity: 0; }
  }
  @media (max-width: 640px) {
    .achievement-toast { left: 1rem; right: 1rem; max-width: none; }
  }
</style>

Cleanup Job

// src/lib/server/jobs/guest-cleanup.ts
import { db } from '$lib/server/db';
import { guest } from '$lib/server/db/schema/progrev';
import { lt, and, isNull } from 'drizzle-orm';

const STALE_DAYS = 30;

export async function cleanupStaleGuests(): Promise<{ deleted: number }> {
  const cutoff = new Date();
  cutoff.setDate(cutoff.getDate() - STALE_DAYS);

  const result = await db
    .delete(guest)
    .where(and(
      lt(guest.lastSeenAt, cutoff),
      isNull(guest.mergedToUserId)
    ))
    .returning({ id: guest.id });

  return { deleted: result.length };
}
// src/routes/api/cron/guest-cleanup/+server.ts
import { json, error } from '@sveltejs/kit';
import { timingSafeEqual } from 'crypto';
import { CRON_SECRET } from '$env/static/private';
import { cleanupStaleGuests } from '$lib/server/jobs/guest-cleanup';

export async function GET({ request }) {
  const auth = request.headers.get('authorization');
  const expected = `Bearer ${CRON_SECRET}`;

  if (!auth || auth.length !== expected.length ||
      !timingSafeEqual(Buffer.from(auth), Buffer.from(expected))) {
    error(401, 'Unauthorized');
  }

  const result = await cleanupStaleGuests();
  return json({ success: true, ...result });
}

Vercel cron:

{
  "crons": [{
    "path": "/api/cron/guest-cleanup",
    "schedule": "0 2 * * *"
  }]
}

File Structure

src/
├── app.d.ts                          # ProgRev types in App namespace
├── hooks.server.ts                   # Guest + ProgRev loading
├── routes/
│   ├── +layout.server.ts             # Pass progrev to page data
│   ├── +layout.svelte                # Cache management, context
│   └── api/cron/guest-cleanup/       # Daily cleanup endpoint
├── lib/
│   ├── types.ts                      # FtuxPhase, ProgRevState
│   ├── progrev/
│   │   ├── ProgRevGate.svelte        # Conditional rendering
│   │   ├── ProgressIndicator.svelte  # Progress display
│   │   ├── AchievementToast.svelte   # Celebration notifications
│   │   └── context.svelte.ts         # useProgRev() helper
│   └── server/
│       ├── db/schema/progrev.ts      # Database schema
│       ├── progrev/
│       │   ├── actions.ts            # recordAction(), createGuest()
│       │   └── merge.ts              # mergeGuestToUser()
│       └── jobs/guest-cleanup.ts     # Stale guest cleanup

Implementation Checklist

Phase 1: Database

  • Create schema in src/lib/server/db/schema/progrev.ts
  • Add ID generators to id.ts
  • Generate and run migration
  • Seed achievement definitions

Phase 2: Server Infrastructure

  • Add hooks for guest identity loading
  • Add hooks for ProgRev state loading
  • Implement recordAction() helper
  • Implement mergeGuestToUser() transaction
  • Create cleanup cron endpoint

Phase 3: Client Components

  • ProgRevGate.svelte - Conditional rendering
  • ProgressIndicator.svelte - Progress display
  • AchievementToast.svelte - Celebrations
  • Context provider in root layout
  • localStorage cache sync

Phase 4: Integration

  • Landing page FTUX flow
  • Sign-up flow with merge
  • Onboarding flow (optional)
  • Sidebar with ProgRev state

← Back to Blueprint